From 7c8997b9339f7d82e77b54eaca75be8077c96a17 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 8 Sep 2026 08:39:52 +0900 Subject: [PATCH 1/3] fix(kit): isolate social providers and guard authentication sessions --- angular.json | 5 +- package-lock.json | 2 +- projects/kit/README.md | 32 +- .../kit/auth-firebase/apple/ng-package.json | 6 + .../kit/auth-firebase/apple/src/kit-apple.ts | 103 ++++++ .../kit/auth-firebase/apple/src/public-api.ts | 2 + .../auth-firebase/facebook/ng-package.json | 6 + .../facebook/src/kit-facebook.ts | 83 +++++ .../auth-firebase/facebook/src/public-api.ts | 2 + .../google/src/kit-google.spec.ts | 31 ++ .../auth-firebase/google/src/kit-google.ts | 51 ++- .../auth-firebase/internal/ng-package.json | 6 + .../auth-firebase/internal/src/kit-oauth.ts | 62 ++++ .../auth-firebase/internal/src/public-api.ts | 2 + .../social/src/kit-social.spec.ts | 212 +++++++++-- .../auth-firebase/social/src/kit-social.ts | 341 ++---------------- projects/kit/docs/optional-features.md | 2 +- projects/kit/tsconfig.spec.json | 4 +- scripts/test-package-consumer.mjs | 81 ++++- tsconfig.json | 4 + 20 files changed, 649 insertions(+), 388 deletions(-) create mode 100644 projects/kit/auth-firebase/apple/ng-package.json create mode 100644 projects/kit/auth-firebase/apple/src/kit-apple.ts create mode 100644 projects/kit/auth-firebase/apple/src/public-api.ts create mode 100644 projects/kit/auth-firebase/facebook/ng-package.json create mode 100644 projects/kit/auth-firebase/facebook/src/kit-facebook.ts create mode 100644 projects/kit/auth-firebase/facebook/src/public-api.ts create mode 100644 projects/kit/auth-firebase/internal/ng-package.json create mode 100644 projects/kit/auth-firebase/internal/src/kit-oauth.ts create mode 100644 projects/kit/auth-firebase/internal/src/public-api.ts diff --git a/angular.json b/angular.json index 589c6b03..39e3ecb8 100644 --- a/angular.json +++ b/angular.json @@ -266,7 +266,10 @@ "../forms/src/**/*.spec.ts", "../offline/src/**/*.spec.ts", "../auth-firebase/src/**/*.spec.ts", - "../auth-firebase/social/src/**/*.spec.ts" + "../auth-firebase/social/src/**/*.spec.ts", + "../auth-firebase/google/src/**/*.spec.ts", + "../auth-firebase/apple/src/**/*.spec.ts", + "../auth-firebase/facebook/src/**/*.spec.ts" ] }, "configurations": { diff --git a/package-lock.json b/package-lock.json index ffeb1a33..30704b3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2555,7 +2555,7 @@ }, "node_modules/@capacitor-community/apple-sign-in": { "version": "8.0.0-beta.0", - "resolved": "git+ssh://git@github.com/rdlabo/apple-sign-in.git#48afdbd60423c56a1ef88e76e2eb13bdf1ac9f7f", + "resolved": "git+ssh://git@github.com/rdlabo/apple-sign-in.git#8a7119b243f64facfee9519a9e5072634ccb5dbe", "dev": true, "license": "MIT", "dependencies": { diff --git a/projects/kit/README.md b/projects/kit/README.md index 51929aac..0f5812ed 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -62,21 +62,27 @@ Firebase, social login, Live Update, Preferences, Status Bar, in-app review, and The `/auth-firebase/google` entry point uses `@capawesome/capacitor-google-sign-in` 0.1.x and therefore requires Capacitor 8. Applications remaining on Capacitor 7 can continue to use the core Kit and other compatible entry points, but cannot use this Google entry point. +Install only the native plugin for the provider entry point you import. `/auth-firebase/social` is deprecated but retained for backwards compatibility; it reexports Apple and Facebook and therefore requires both plugins. Migrate imports to `/auth-firebase/apple` and `/auth-firebase/facebook` when convenient; removal is not part of this change. `/auth-firebase/internal` is a provider-neutral shared implementation used by those entry points; it is not a supported application API. + +Social login callbacks pin request identity via the authenticated `user` on the success payload. Failures in `before`/`success` (and Google `exchange`) are reported to `error`, return `{ status: false }`, and always run `finally`. Failures inside `error`/`finally` themselves reject the call. Native Apple runs on iOS only; web uses the Firebase popup. Cancellation is classified only when the native adapter preserves a cancellation code; unknown native errors are `other` (do not infer cancellation from localized messages). + ## Entry points -| Import | Responsibility | -| ------------------------------------------------ | ---------------------------------------------------------------------------------- | -| `@rdlabo/ionic-angular-kit` | Storage, overlays, guards, HTTP, realtime, directives, keyboard, and utilities | -| `@rdlabo/ionic-angular-kit/offline` | **Experimental.** Scoped local replica, outbox, pull, replay, and request policies | -| `@rdlabo/ionic-angular-kit/theme` | Persisted light/dark theme and native status bar sync | -| `@rdlabo/ionic-angular-kit/forms` | Ionic error text and state classes for Angular Signal Forms | -| `@rdlabo/ionic-angular-kit/review` | Throttled native in-app review requests | -| `@rdlabo/ionic-angular-kit/printer` | DOM-to-PNG, Brother label, and PDF helpers | -| `@rdlabo/ionic-angular-kit/auth-firebase` | Firebase dependency wiring and authentication flows | -| `@rdlabo/ionic-angular-kit/auth-firebase/google` | Google popup/native sign-in, Firebase session linking, and logout | -| `@rdlabo/ionic-angular-kit/auth-firebase/social` | Apple and Facebook Firebase social-auth helpers | -| `@rdlabo/ionic-angular-kit/app-update` | Atomic Angular service-worker update transitions | -| `@rdlabo/ionic-angular-kit/live-update` | Capawesome Live Update readiness provider | +| Import | Responsibility | +| -------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `@rdlabo/ionic-angular-kit` | Storage, overlays, guards, HTTP, realtime, directives, keyboard, and utilities | +| `@rdlabo/ionic-angular-kit/offline` | **Experimental.** Scoped local replica, outbox, pull, replay, and request policies | +| `@rdlabo/ionic-angular-kit/theme` | Persisted light/dark theme and native status bar sync | +| `@rdlabo/ionic-angular-kit/forms` | Ionic error text and state classes for Angular Signal Forms | +| `@rdlabo/ionic-angular-kit/review` | Throttled native in-app review requests | +| `@rdlabo/ionic-angular-kit/printer` | DOM-to-PNG, Brother label, and PDF helpers | +| `@rdlabo/ionic-angular-kit/auth-firebase` | Firebase dependency wiring and authentication flows | +| `@rdlabo/ionic-angular-kit/auth-firebase/apple` | Apple sign-in/link (own Apple plugin only) | +| `@rdlabo/ionic-angular-kit/auth-firebase/facebook` | Facebook login/link/logout (own Facebook plugin only) | +| `@rdlabo/ionic-angular-kit/auth-firebase/google` | Google popup/native sign-in, Firebase session linking, and logout | +| `@rdlabo/ionic-angular-kit/auth-firebase/social` | **Deprecated, retained for compatibility.** Apple/Facebook helpers | +| `@rdlabo/ionic-angular-kit/app-update` | Atomic Angular service-worker update transitions | +| `@rdlabo/ionic-angular-kit/live-update` | Capawesome Live Update readiness provider | Secondary entry points isolate optional native and SDK dependencies from the core bundle. diff --git a/projects/kit/auth-firebase/apple/ng-package.json b/projects/kit/auth-firebase/apple/ng-package.json new file mode 100644 index 00000000..35ba3bbd --- /dev/null +++ b/projects/kit/auth-firebase/apple/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/kit/auth-firebase/apple/src/kit-apple.ts b/projects/kit/auth-firebase/apple/src/kit-apple.ts new file mode 100644 index 00000000..e513b304 --- /dev/null +++ b/projects/kit/auth-firebase/apple/src/kit-apple.ts @@ -0,0 +1,103 @@ +import type { Auth, User } from 'firebase/auth'; +import { + EmailAuthProvider, + linkWithCredential, + linkWithPopup, + OAuthProvider, + reauthenticateWithPopup, + signInWithPopup, +} from 'firebase/auth'; +import { Capacitor } from '@capacitor/core'; +import { SignInWithApple } from '@capacitor-community/apple-sign-in'; +import { applyOAuthCredential, assertCurrentUser, classifyOAuthError, requireUser } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import type { KitOAuthMode, KitOAuthModeName, KitSocialHooks } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; + +/** Apple credentials. Native authorization codes and web access tokens are distinct values. */ +export interface KitAppleResponse { + user: string | null; + email: string | null; + givenName: string | null; + familyName: string | null; + identityToken: string | null; + /** One-time authorization code from native Apple sign-in; null on web. */ + authorizationCode: string | null; + /** Apple access token from the Firebase web popup; null on native. + * Optional for source compatibility with existing Apple response objects. Kit always supplies it. + */ + accessToken?: string | null; +} + +/** Apple login options. The success hook receives the exact authenticated Firebase user. */ +export type KitAppleLoginOptions = KitOAuthMode & KitSocialHooks<{ response: KitAppleResponse; mode: KitOAuthModeName; user: User }>; + +const emptyAppleResponse = (): KitAppleResponse => ({ + user: null, + email: null, + givenName: null, + familyName: null, + identityToken: null, + authorizationCode: null, + accessToken: null, +}); + +const classifyAppleError = (error: unknown) => { + const code = (error as { code?: string | number } | null)?.code; + // ASAuthorizationError.canceled, when the native adapter preserves its code. + // Adapters that only expose a localized message must be treated as an operational error. + return code === 1001 || code === '1001' ? 'cancelled' : classifyOAuthError(error); +}; + +/** Apple sign-in/link on iOS and web. Android is unsupported by the native Apple plugin. + * Pins the user before any asynchronous work and reports SDK and app-hook failures to error. + */ +export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): Promise<{ status: boolean }> => { + const expectedUser = auth.currentUser; + const execute = async (): Promise => { + await options.before?.(); + assertCurrentUser(auth, expectedUser); + let response: KitAppleResponse; + let user: User; + if (Capacitor.isNativePlatform()) { + if (Capacitor.getPlatform() !== 'ios') throw new Error('kit Apple login: native platform is not supported'); + const { response: native } = await SignInWithApple.authorize(); + response = { ...emptyAppleResponse(), ...native }; + if (!response.identityToken) throw new Error('kit Apple login: identity token is missing'); + const credential = new OAuthProvider('apple.com').credential({ idToken: response.identityToken }); + user = await applyOAuthCredential(auth, credential, options, expectedUser); + } else { + const provider = new OAuthProvider('apple.com'); + provider.addScope('email'); + provider.addScope('name'); + if (options.mode === 'credential') { + user = requireUser(expectedUser); + await reauthenticateWithPopup(user, provider); + assertCurrentUser(auth, user); + await linkWithCredential(user, EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password)); + response = emptyAppleResponse(); + } else { + const result = + options.mode === 'new' ? await signInWithPopup(auth, provider) : await linkWithPopup(requireUser(expectedUser), provider); + user = result.user; + const credential = OAuthProvider.credentialFromResult(result); + response = { + ...emptyAppleResponse(), + email: user.email, + identityToken: credential?.idToken ?? null, + accessToken: credential?.accessToken ?? null, + }; + } + } + assertCurrentUser(auth, user); + await options.success?.({ response, mode: options.mode, user }); + assertCurrentUser(auth, user); + }; + return execute() + .then( + () => ({ status: true }), + async (error: unknown) => { + await options.error?.(classifyAppleError(error), error); + return { status: false }; + }, + ) + .finally(() => options.finally?.()); +}; diff --git a/projects/kit/auth-firebase/apple/src/public-api.ts b/projects/kit/auth-firebase/apple/src/public-api.ts new file mode 100644 index 00000000..c47e9721 --- /dev/null +++ b/projects/kit/auth-firebase/apple/src/public-api.ts @@ -0,0 +1,2 @@ +export * from './kit-apple'; +export type { KitOAuthErrorCategory, KitOAuthMode, KitOAuthModeName } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; diff --git a/projects/kit/auth-firebase/facebook/ng-package.json b/projects/kit/auth-firebase/facebook/ng-package.json new file mode 100644 index 00000000..35ba3bbd --- /dev/null +++ b/projects/kit/auth-firebase/facebook/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/kit/auth-firebase/facebook/src/kit-facebook.ts b/projects/kit/auth-firebase/facebook/src/kit-facebook.ts new file mode 100644 index 00000000..abd1d7cf --- /dev/null +++ b/projects/kit/auth-firebase/facebook/src/kit-facebook.ts @@ -0,0 +1,83 @@ +import type { Auth, User } from 'firebase/auth'; +import { FacebookAuthProvider, OAuthProvider } from 'firebase/auth'; +import { Capacitor } from '@capacitor/core'; +import { FacebookLogin } from '@capacitor-community/facebook-login'; +import { applyOAuthCredential, assertCurrentUser, classifyOAuthError } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import type { KitOAuthMode, KitOAuthModeName, KitSocialHooks } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; + +/** Facebook login options. Permissions and app effects are owned by the consumer. */ +export type KitFacebookLoginOptions = KitOAuthMode & + KitSocialHooks<{ accessToken: string; mode: KitOAuthModeName; user: User }> & { + permissions: string[]; + }; + +const generateNonce = (): string => + Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join(''); + +/** Await one animation frame where available (iOS WebView crash workaround; no-op off-browser). */ +const nextFrame = (): Promise => + new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => resolve()); + } else { + resolve(); + } + }); + +/** Web plugin cancellation shape: the login promise rejects with a response whose token is null. */ +const isFacebookCancellation = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null || !('accessToken' in error)) { + return false; + } + const { accessToken } = error; + if (typeof accessToken !== 'object' || accessToken === null || !('token' in accessToken)) { + return false; + } + return accessToken.token === null; +}; + +/** Facebook login/link with a pinned Firebase user and classified failure hooks. */ +export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOptions): Promise<{ status: boolean }> => { + const expectedUser = auth.currentUser; + const execute = async (): Promise => { + await options.before?.(); + assertCurrentUser(auth, expectedUser); + const nonce = generateNonce(); + const event = await FacebookLogin.login({ permissions: options.permissions, nonce }).finally(nextFrame); + if (!event?.accessToken?.token) throw Object.assign(new Error('Facebook login cancelled'), { code: 'auth/user-cancelled' }); + const accessToken = event.accessToken.token; + const credential = + Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios' + ? new OAuthProvider('facebook.com').credential({ rawNonce: nonce, idToken: accessToken }) + : FacebookAuthProvider.credential(accessToken); + const user = await applyOAuthCredential(auth, credential, options, expectedUser); + await options.success?.({ accessToken, mode: options.mode, user }); + assertCurrentUser(auth, user); + }; + return execute() + .then( + () => ({ status: true }), + async (error: unknown) => { + await options.error?.(isFacebookCancellation(error) ? 'cancelled' : classifyOAuthError(error), error); + return { status: false }; + }, + ) + .finally(() => options.finally?.()); +}; + +/** + * Log out of the Facebook SDK (best-effort; errors are ignored). + * + * @remarks + * Apps that offer Facebook login typically call this alongside the Firebase sign-out, so it lives + * here to keep the `@capacitor-community/facebook-login` import out of the app. + * Skips the call when the Facebook SDK has no active session — otherwise `FB.logout()` logs + * "called without an access token" on web and native rejects for email/password users. + */ +export const kitFacebookLogout = async (): Promise => { + const session = await FacebookLogin.getCurrentAccessToken().catch(() => null); + if (!session?.accessToken?.token) { + return; + } + await FacebookLogin.logout().catch(() => undefined); +}; diff --git a/projects/kit/auth-firebase/facebook/src/public-api.ts b/projects/kit/auth-firebase/facebook/src/public-api.ts new file mode 100644 index 00000000..a2026af3 --- /dev/null +++ b/projects/kit/auth-firebase/facebook/src/public-api.ts @@ -0,0 +1,2 @@ +export * from './kit-facebook'; +export type { KitOAuthErrorCategory, KitOAuthMode, KitOAuthModeName } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; diff --git a/projects/kit/auth-firebase/google/src/kit-google.spec.ts b/projects/kit/auth-firebase/google/src/kit-google.spec.ts index f1ce9987..e3ddaf5c 100644 --- a/projects/kit/auth-firebase/google/src/kit-google.spec.ts +++ b/projects/kit/auth-firebase/google/src/kit-google.spec.ts @@ -237,3 +237,34 @@ it('makes plugin logout best effort', async () => { signOut.mockRejectedValue(new Error('already signed out')); await expect(kitGoogleLogout()).resolves.toBeUndefined(); }); + +it('does not sign in a native candidate over a session changed during the plugin prompt', async () => { + getPlatform.mockReturnValue('ios'); + initialize.mockResolvedValue(undefined); + const auth = authWith(null); + const replacement = { uid: 'replacement' }; + nativeSignIn.mockImplementationOnce(async () => { + Object.assign(auth, { currentUser: replacement }); + return { idToken: 'candidate-token' }; + }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(auth, { mode: 'new', clientId: 'race-client', exchange })).resolves.toEqual({ status: false }); + expect(signInWithCredential).not.toHaveBeenCalled(); + expect(exchange).not.toHaveBeenCalled(); + expect(auth.currentUser).toBe(replacement); +}); + +it('pins the user before the asynchronous before hook', async () => { + getPlatform.mockReturnValue('web'); + const auth = authWith({ uid: 'original' }); + await expect( + kitGoogleLogin(auth, { + mode: 'link', + clientId: 'client', + before: async () => { + Object.assign(auth, { currentUser: { uid: 'replacement' } }); + }, + }), + ).resolves.toEqual({ status: false }); + expect(linkWithPopup).not.toHaveBeenCalled(); +}); diff --git a/projects/kit/auth-firebase/google/src/kit-google.ts b/projects/kit/auth-firebase/google/src/kit-google.ts index 1c36590f..7e184f57 100644 --- a/projects/kit/auth-firebase/google/src/kit-google.ts +++ b/projects/kit/auth-firebase/google/src/kit-google.ts @@ -1,5 +1,7 @@ import { ErrorCode, GoogleSignIn } from '@capawesome/capacitor-google-sign-in'; import { Capacitor } from '@capacitor/core'; +import { assertCurrentUser, classifyOAuthError, requireUser } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import type { KitOAuthErrorCategory } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; import type { Auth, User } from 'firebase/auth'; import { EmailAuthProvider, @@ -12,12 +14,15 @@ import { signInWithPopup, } from 'firebase/auth'; +/** Sign in, link Google, reauthenticate, or add email/password after reauthentication. */ export type KitGoogleLoginMode = | { mode: 'new' } | { mode: 'link' } | { mode: 'reauthenticate' } | { mode: 'credential'; emailLogin: { email: string; password: string } }; -export type KitGoogleErrorCategory = 'already-in-use' | 'cancelled' | 'other'; +/** App-facing Google authentication error category. */ +export type KitGoogleErrorCategory = KitOAuthErrorCategory; +/** Google login configuration. exchange runs for new/link; hooks receive the authenticated user. */ export type KitGoogleLoginOptions = KitGoogleLoginMode & { clientId: string; before?: () => void | Promise; @@ -41,40 +46,24 @@ const initialize = (clientId: string): Promise => { return promise; }; -const requireUser = (auth: Auth): User => { - if (!auth.currentUser) throw new Error('kit Google login: no signed-in Firebase user'); - return auth.currentUser; -}; - -const assertCurrentUser = (auth: Auth, user: User): void => { - if (auth.currentUser !== user) { - throw new Error('kit Google login: Firebase user changed'); - } -}; - -const classify = (error: unknown): KitGoogleErrorCategory => { - const code = (error as { code?: string } | null)?.code; - if (code === 'auth/credential-already-in-use') return 'already-in-use'; - if ( - code === ErrorCode.SignInCanceled || - code === 'auth/user-cancelled' || - code === 'auth/popup-closed-by-user' || - code === 'auth/cancelled-popup-request' - ) - return 'cancelled'; - return 'other'; -}; +const classify = (error: unknown): KitGoogleErrorCategory => + (error as { code?: string } | null)?.code === ErrorCode.SignInCanceled ? 'cancelled' : classifyOAuthError(error); +/** Google popup/native authentication with pinned users and app-owned exchange/feedback hooks. + * Failures in before/exchange/success are reported to error and return status:false. + * finally always runs; failures in error/finally themselves reject the call. + */ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions): Promise<{ status: boolean }> => { + const expectedUser = auth.currentUser; const execute = async (): Promise => { await options.before?.(); - const expectedUser = auth.currentUser; + assertCurrentUser(auth, expectedUser); let idToken: string; let user: User; if (Capacitor.getPlatform() === 'web') { const provider = new GoogleAuthProvider(); if (options.mode === 'reauthenticate') { - user = requireUser(auth); + user = requireUser(expectedUser); const result = await reauthenticateWithPopup(user, provider); idToken = GoogleAuthProvider.credentialFromResult(result)?.idToken ?? ''; assertCurrentUser(auth, user); @@ -83,7 +72,7 @@ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions) return; } if (options.mode === 'credential') { - user = requireUser(auth); + user = requireUser(expectedUser); await reauthenticateWithPopup(user, provider); assertCurrentUser(auth, user); await linkWithCredential(user, EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password)); @@ -92,7 +81,8 @@ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions) assertCurrentUser(auth, user); return; } - const result = options.mode === 'new' ? await signInWithPopup(auth, provider) : await linkWithPopup(requireUser(auth), provider); + const result = + options.mode === 'new' ? await signInWithPopup(auth, provider) : await linkWithPopup(requireUser(expectedUser), provider); const credential = GoogleAuthProvider.credentialFromResult(result); if (!credential?.idToken) throw new Error('kit Google login: ID token is missing'); idToken = credential.idToken; @@ -100,11 +90,11 @@ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions) } else { await initialize(options.clientId); idToken = (await GoogleSignIn.signIn()).idToken; + assertCurrentUser(auth, expectedUser); const credential = GoogleAuthProvider.credential(idToken); if (options.mode === 'new') user = (await signInWithCredential(auth, credential)).user; else { - user = requireUser(auth); - if (user !== expectedUser) throw new Error('kit Google login: Firebase user changed'); + user = requireUser(expectedUser); if (options.mode === 'link') await linkWithCredential(user, credential); else if (options.mode === 'reauthenticate') { await reauthenticateWithCredential(user, credential); @@ -140,4 +130,5 @@ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions) .finally(() => options.finally?.()); }; +/** Best-effort native Google SDK sign-out. */ export const kitGoogleLogout = (): Promise => GoogleSignIn.signOut().catch(() => undefined); diff --git a/projects/kit/auth-firebase/internal/ng-package.json b/projects/kit/auth-firebase/internal/ng-package.json new file mode 100644 index 00000000..35ba3bbd --- /dev/null +++ b/projects/kit/auth-firebase/internal/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/kit/auth-firebase/internal/src/kit-oauth.ts b/projects/kit/auth-firebase/internal/src/kit-oauth.ts new file mode 100644 index 00000000..f68831e1 --- /dev/null +++ b/projects/kit/auth-firebase/internal/src/kit-oauth.ts @@ -0,0 +1,62 @@ +import type { Auth, AuthCredential, User } from 'firebase/auth'; +import { EmailAuthProvider, linkWithCredential, reauthenticateWithCredential, signInWithCredential } from 'firebase/auth'; + +/** The app-facing classification of a social authentication failure. */ +export type KitOAuthErrorCategory = 'already-in-use' | 'cancelled' | 'other'; +/** Sign in, link a provider, or reauthenticate before adding email/password login. */ +export type KitOAuthMode = { mode: 'new' } | { mode: 'link' } | { mode: 'credential'; emailLogin: { email: string; password: string } }; +/** The discriminator of a social authentication operation. */ +export type KitOAuthModeName = KitOAuthMode['mode']; + +/** App-owned effects. Failures in before/success are reported to error; finally always runs. + * A handled failure returns status:false. A failure in error/finally itself rejects the call. + */ +export interface KitSocialHooks { + before?: () => void | Promise; + success?: (info: Info) => void | Promise; + error?: (category: KitOAuthErrorCategory, error: unknown) => void | Promise; + finally?: () => void | Promise; +} + +/** @internal Classify Firebase SDK failures without inspecting localized messages. */ +export const classifyOAuthError = (error: unknown): KitOAuthErrorCategory => { + const code = (error as { code?: string | number } | null)?.code; + if (code === 'auth/credential-already-in-use') return 'already-in-use'; + if (code === 'auth/user-cancelled' || code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') return 'cancelled'; + return 'other'; +}; + +/** @internal Require the identity captured at the start of a linking operation. */ +export const requireUser = (user: User | null): User => { + if (!user) throw new Error('kit social: no signed-in user to link/re-authenticate'); + return user; +}; + +/** @internal Abort follow-up effects if the Firebase identity changed during an await. */ +export const assertCurrentUser = (auth: Auth, expectedUser: User | null): void => { + if (auth.currentUser !== expectedUser) throw new Error('kit social: Firebase user changed'); +}; + +/** @internal Apply a provider credential to the captured user and return the authenticated identity. */ +export const applyOAuthCredential = async ( + auth: Auth, + credential: AuthCredential, + mode: KitOAuthMode, + expectedUser: User | null, +): Promise => { + assertCurrentUser(auth, expectedUser); + if (mode.mode === 'new') { + const { user } = await signInWithCredential(auth, credential); + assertCurrentUser(auth, user); + return user; + } + const user = requireUser(expectedUser); + if (mode.mode === 'link') await linkWithCredential(user, credential); + else { + await reauthenticateWithCredential(user, credential); + assertCurrentUser(auth, user); + await linkWithCredential(user, EmailAuthProvider.credential(mode.emailLogin.email, mode.emailLogin.password)); + } + assertCurrentUser(auth, user); + return user; +}; diff --git a/projects/kit/auth-firebase/internal/src/public-api.ts b/projects/kit/auth-firebase/internal/src/public-api.ts new file mode 100644 index 00000000..ce1d13e4 --- /dev/null +++ b/projects/kit/auth-firebase/internal/src/public-api.ts @@ -0,0 +1,2 @@ +/** @internal Shared implementation for kit auth entry points. Not a supported application API. */ +export * from './kit-oauth'; diff --git a/projects/kit/auth-firebase/social/src/kit-social.spec.ts b/projects/kit/auth-firebase/social/src/kit-social.spec.ts index 30db434a..cdc2ad71 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.spec.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.spec.ts @@ -1,4 +1,7 @@ -import type { Auth } from 'firebase/auth'; +import { kitAppleLogin as appleLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; +import { kitFacebookLogin as facebookFlow, kitFacebookLogout as facebookLogoutFlow } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; +import type { Auth, User } from 'firebase/auth'; +// Compatibility entrypoint: re-exports apple/facebook (keep exercised). import { kitAppleLogin, kitFacebookLogin, kitFacebookLogout } from './kit-social'; const signInWithCredential = vi.fn(); @@ -55,6 +58,17 @@ vi.mock('@capacitor-community/apple-sign-in', () => ({ const fbError = (code: string) => Object.assign(new Error(code), { code }); const authWith = (currentUser: unknown): Auth => ({ currentUser }) as unknown as Auth; +const setCurrentUser = (auth: Auth, user: User | null) => { + (auth as unknown as { currentUser: User | null }).currentUser = user; +}; + +/** Successful Firebase credential sign-in must pin auth.currentUser to the returned user. */ +const mockSignInSuccess = (auth: Auth, user: User) => { + signInWithCredential.mockImplementationOnce(async () => { + setCurrentUser(auth, user); + return { user }; + }); +}; const hooks = () => ({ before: vi.fn().mockResolvedValue(undefined), @@ -74,30 +88,32 @@ afterEach(() => { describe('kitFacebookLogin', () => { it("mode 'new' signs in, then runs before → success (with payload) → finally", async () => { + const user = { uid: 'u1' } as User; + const auth = authWith(null); facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); - signInWithCredential.mockResolvedValueOnce({ user: { uid: 'u1' } }); + mockSignInSuccess(auth, user); const h = hooks(); - const res = await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h }); + const res = await kitFacebookLogin(auth, { mode: 'new', permissions: [], ...h }); expect(res).toEqual({ status: true }); expect(signInWithCredential).toHaveBeenCalled(); expect(h.before).toHaveBeenCalledTimes(1); - expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'new' }); + expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'new', user }); expect(h.error).not.toHaveBeenCalled(); expect(h.finally).toHaveBeenCalledTimes(1); }); - it('returns {status:false} without reporting when the plugin login is cancelled', async () => { + it('reports plugin login cancellation via error(cancelled)', async () => { facebookLogin.mockResolvedValueOnce(undefined); const h = hooks(); const res = await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h }); expect(res).toEqual({ status: false }); - expect(h.error).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('cancelled', expect.objectContaining({ code: 'auth/user-cancelled' })); expect(signInWithCredential).not.toHaveBeenCalled(); }); - it('treats the web plugin null-token rejection as a silent cancellation', async () => { + it('reports the web plugin null-token rejection as cancelled', async () => { isNativePlatform.mockReturnValue(false); facebookLogin.mockRejectedValueOnce({ accessToken: { token: null } }); const animationFrame = vi.spyOn(globalThis, 'requestAnimationFrame'); @@ -106,7 +122,7 @@ describe('kitFacebookLogin', () => { await expect(kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h })).resolves.toEqual({ status: false }); expect(animationFrame).toHaveBeenCalledOnce(); - expect(h.error).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('cancelled', expect.anything()); expect(signInWithCredential).not.toHaveBeenCalled(); expect(h.finally).toHaveBeenCalledOnce(); }); @@ -137,15 +153,15 @@ describe('kitFacebookLogin', () => { expect(h.finally).toHaveBeenCalledOnce(); }); - it('rethrows a before-hook failure after running finally without calling the plugin', async () => { + it('reports a before-hook failure with status:false without calling the plugin', async () => { const boom = new Error('preflight failed'); const h = hooks(); h.before.mockRejectedValueOnce(boom); - await expect(kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h })).rejects.toBe(boom); + await expect(kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h })).resolves.toEqual({ status: false }); expect(facebookLogin).not.toHaveBeenCalled(); - expect(h.error).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('other', boom); expect(h.finally).toHaveBeenCalledOnce(); }); @@ -163,30 +179,34 @@ describe('kitFacebookLogin', () => { it('uses the iOS OIDC nonce path (OAuthProvider) on native iOS', async () => { isNativePlatform.mockReturnValue(true); getPlatform.mockReturnValue('ios'); + const user = { uid: 'ios' } as User; + const auth = authWith(null); facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); - signInWithCredential.mockResolvedValueOnce({}); + mockSignInSuccess(auth, user); const h = hooks(); - await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h }); + await kitFacebookLogin(auth, { mode: 'new', permissions: [], ...h }); const cred = signInWithCredential.mock.calls[0][1] as { providerId?: string }; expect(cred.providerId).toBe('facebook.com'); // OAuthProvider credential, not FacebookAuthProvider }); it("mode 'link' links then afterCredential + onSuccess", async () => { + const user = { uid: 'u1' } as User; facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); linkWithCredential.mockResolvedValueOnce({}); const h = hooks(); - const res = await kitFacebookLogin(authWith({ uid: 'u1' }), { mode: 'link', permissions: [], ...h }); + const res = await kitFacebookLogin(authWith(user), { mode: 'link', permissions: [], ...h }); expect(res).toEqual({ status: true }); expect(linkWithCredential).toHaveBeenCalled(); - expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'link' }); + expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'link', user }); }); it("mode 'credential' re-auths then links the email credential", async () => { + const user = { uid: 'u1' } as User; facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); reauthenticateWithCredential.mockResolvedValueOnce({}); linkWithCredential.mockResolvedValueOnce({}); const h = hooks(); - const res = await kitFacebookLogin(authWith({ uid: 'u1' }), { + const res = await kitFacebookLogin(authWith(user), { mode: 'credential', emailLogin: { email: 'e@x.com', password: 'pw' }, permissions: [], @@ -194,55 +214,153 @@ describe('kitFacebookLogin', () => { }); expect(res).toEqual({ status: true }); expect(reauthenticateWithCredential).toHaveBeenCalled(); - expect(linkWithCredential).toHaveBeenCalledWith({ uid: 'u1' }, { email: 'e@x.com', password: 'pw' }); + expect(linkWithCredential).toHaveBeenCalledWith(user, { email: 'e@x.com', password: 'pw' }); + }); + + it('does not link or succeed when currentUser switches during pending native Facebook login', async () => { + const original = { uid: 'u1' } as User; + const auth = authWith(original); + facebookLogin.mockImplementationOnce(async () => { + setCurrentUser(auth, { uid: 'switched' } as User); + return { accessToken: { token: 'tok' } }; + }); + const h = hooks(); + + await expect(kitFacebookLogin(auth, { mode: 'link', permissions: [], ...h })).resolves.toEqual({ status: false }); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(h.success).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('other', expect.objectContaining({ message: 'kit social: Firebase user changed' })); + }); + + it('does not link a password when currentUser switches during credential reauthentication', async () => { + const original = { uid: 'u1' } as User; + const auth = authWith(original); + facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } }); + reauthenticateWithCredential.mockImplementationOnce(async () => { + setCurrentUser(auth, { uid: 'switched' } as User); + }); + const h = hooks(); + + await expect( + kitFacebookLogin(auth, { + mode: 'credential', + emailLogin: { email: 'e@x.com', password: 'pw' }, + permissions: [], + ...h, + }), + ).resolves.toEqual({ status: false }); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(h.success).not.toHaveBeenCalled(); }); }); describe('kitAppleLogin', () => { - it('rethrows a before-hook failure after running finally without calling the plugin', async () => { + it('reports a before-hook failure with status:false without calling the plugin', async () => { const boom = new Error('preflight failed'); const h = hooks(); h.before.mockRejectedValueOnce(boom); - await expect(kitAppleLogin(authWith(null), { mode: 'new', ...h })).rejects.toBe(boom); + await expect(kitAppleLogin(authWith(null), { mode: 'new', ...h })).resolves.toEqual({ status: false }); expect(appleAuthorize).not.toHaveBeenCalled(); - expect(h.error).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('other', boom); expect(h.finally).toHaveBeenCalledOnce(); }); it('native: authorizes, applies credential, success gets the apple response', async () => { isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); + const user = { uid: 'apple' } as User; + const auth = authWith(null); appleAuthorize.mockResolvedValueOnce({ response: { identityToken: 'it', email: 'a@b.com' } }); - signInWithCredential.mockResolvedValueOnce({}); + mockSignInSuccess(auth, user); const h = hooks(); - const res = await kitAppleLogin(authWith(null), { mode: 'new', ...h }); + const res = await kitAppleLogin(auth, { mode: 'new', ...h }); expect(res).toEqual({ status: true }); expect(h.success).toHaveBeenCalledWith({ response: expect.objectContaining({ identityToken: 'it', email: 'a@b.com' }), mode: 'new', + user, }); expect(h.finally).toHaveBeenCalledTimes(1); }); - it('native: cancelled authorize → {status:false}', async () => { + it('native: authorize undefined is an other error rather than silent cancellation', async () => { isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); appleAuthorize.mockResolvedValueOnce(undefined); const h = hooks(); expect(await kitAppleLogin(authWith(null), { mode: 'new', ...h })).toEqual({ status: false }); + expect(h.error).toHaveBeenCalledWith('other', expect.anything()); + expect(signInWithCredential).not.toHaveBeenCalled(); + }); + + it('native: operational reject is reported with the original error', async () => { + isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); + const boom = new Error('ASAuthorization failed'); + appleAuthorize.mockRejectedValueOnce(boom); + const h = hooks(); + + await expect(kitAppleLogin(authWith(null), { mode: 'new', ...h })).resolves.toEqual({ status: false }); + + expect(h.error).toHaveBeenCalledWith('other', boom); expect(signInWithCredential).not.toHaveBeenCalled(); }); + it.each([1001, '1001'])('native: classifies ASAuthorizationError.canceled (%s) as cancelled', async (code) => { + isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); + const cancelled = Object.assign(new Error('canceled'), { code }); + appleAuthorize.mockRejectedValueOnce(cancelled); + const h = hooks(); + + await expect(kitAppleLogin(authWith(null), { mode: 'new', ...h })).resolves.toEqual({ status: false }); + + expect(h.error).toHaveBeenCalledWith('cancelled', cancelled); + }); + + it('does not link or succeed when currentUser switches during pending native Apple login', async () => { + isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); + const original = { uid: 'u1' } as User; + const auth = authWith(original); + appleAuthorize.mockImplementationOnce(async () => { + setCurrentUser(auth, { uid: 'switched' } as User); + return { response: { identityToken: 'it', email: 'a@b.com' } }; + }); + const h = hooks(); + + await expect(kitAppleLogin(auth, { mode: 'link', ...h })).resolves.toEqual({ status: false }); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(h.success).not.toHaveBeenCalled(); + expect(h.error).toHaveBeenCalledWith('other', expect.objectContaining({ message: 'kit social: Firebase user changed' })); + }); + it("web 'new': uses signInWithPopup, synthesizes the response, routes errors to error", async () => { isNativePlatform.mockReturnValue(false); - signInWithPopup.mockResolvedValueOnce({ user: { email: 'a@b.com' } }); + const user = { email: 'a@b.com' } as User; + const auth = authWith(null); + signInWithPopup.mockImplementationOnce(async () => { + setCurrentUser(auth, user); + return { user }; + }); const h = hooks(); - const res = await kitAppleLogin(authWith(null), { mode: 'new', ...h }); + const res = await kitAppleLogin(auth, { mode: 'new', ...h }); expect(res).toEqual({ status: true }); expect(signInWithPopup).toHaveBeenCalled(); expect(h.success).toHaveBeenCalledWith({ - response: expect.objectContaining({ email: 'a@b.com', identityToken: 'id-token' }), + response: expect.objectContaining({ + email: 'a@b.com', + identityToken: 'id-token', + authorizationCode: null, + accessToken: 'access-token', + }), mode: 'new', + user, }); signInWithPopup.mockRejectedValueOnce(fbError('auth/popup-closed-by-user')); @@ -277,3 +395,45 @@ describe('kitFacebookLogout', () => { expect(facebookLogout).not.toHaveBeenCalled(); }); }); + +describe('Apple credential identity boundary', () => { + it.each([false, true])('does not add a password after the session changes during reauthentication (native=%s)', async (native) => { + isNativePlatform.mockReturnValue(native); + getPlatform.mockReturnValue('ios'); + const original = { uid: 'original' } as User; + const auth = authWith(original); + const changed = { uid: 'changed' } as User; + appleAuthorize.mockResolvedValueOnce({ response: { identityToken: 'apple-token' } }); + const reauthenticate = native ? reauthenticateWithCredential : reauthenticateWithPopup; + reauthenticate.mockImplementationOnce(async () => { + setCurrentUser(auth, changed); + return { user: original }; + }); + const h = hooks(); + await expect( + kitAppleLogin(auth, { mode: 'credential', emailLogin: { email: 'user@example.com', password: 'password' }, ...h }), + ).resolves.toEqual({ status: false }); + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(h.success).not.toHaveBeenCalled(); + expect(auth.currentUser).toBe(changed); + }); + + it('reports an app success-hook failure and still runs finally', async () => { + isNativePlatform.mockReturnValue(false); + const user = { uid: 'current' } as User; + const auth = authWith(user); + linkWithPopup.mockResolvedValueOnce({ user }); + const failure = new Error('backend unavailable'); + const h = hooks(); + h.success.mockRejectedValueOnce(failure); + await expect(kitAppleLogin(auth, { mode: 'link', ...h })).resolves.toEqual({ status: false }); + expect(h.error).toHaveBeenCalledWith('other', failure); + expect(h.finally).toHaveBeenCalledOnce(); + }); +}); + +it('keeps deprecated social exports as the same provider implementations', () => { + expect(kitAppleLogin).toBe(appleLogin); + expect(kitFacebookLogin).toBe(facebookFlow); + expect(kitFacebookLogout).toBe(facebookLogoutFlow); +}); diff --git a/projects/kit/auth-firebase/social/src/kit-social.ts b/projects/kit/auth-firebase/social/src/kit-social.ts index 16847d45..c13f7ecc 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.ts @@ -1,312 +1,29 @@ -import type { Auth, AuthCredential } from 'firebase/auth'; -import { - EmailAuthProvider, - FacebookAuthProvider, - linkWithCredential, - linkWithPopup, - OAuthProvider, - reauthenticateWithCredential, - reauthenticateWithPopup, - signInWithCredential, - signInWithPopup, -} from 'firebase/auth'; -import { Capacitor } from '@capacitor/core'; -import { FacebookLogin } from '@capacitor-community/facebook-login'; -import { SignInWithApple } from '@capacitor-community/apple-sign-in'; - -/** How a social-credential failure is classified for the app's error hook. */ -export type KitOAuthErrorCategory = 'already-in-use' | 'cancelled' | 'other'; - -/** The mode a social login runs in. */ -export type KitOAuthModeName = 'new' | 'link' | 'credential'; - -/** - * The mode discriminator. `'credential'` links an email/password to the (re-authenticated) social - * account, so it requires the new email/password; `'new'` / `'link'` do not. - */ -export type KitOAuthMode = { mode: 'new' } | { mode: 'link' } | { mode: 'credential'; emailLogin: { email: string; password: string } }; - -/** - * The apple identity payload handed to the `success` hook for the backend call. Populated from the - * native plugin on device, or synthesized from the popup result on the web. - */ -export interface KitAppleResponse { - user: string | null; - email: string | null; - givenName: string | null; - familyName: string | null; - identityToken: string | null; - authorizationCode: string | null; -} - -/** - * The uniform lifecycle hooks for a social flow — the same `before / success / error / finally` - * shape as {@link KitFirebaseAuthService}'s hooks, so a call site reads the same everywhere. All are - * optional; the kit renders nothing itself. - * - * @typeParam Info - the identity payload handed to {@link success} (Facebook access token / Apple - * response), so an app can notify its backend and give feedback in one place. - * - * @remarks - * `before` runs before the plugin login starts, `success` after the mode's Firebase op succeeds - * (carrying the identity payload — do the backend call and the toast here), `error` on a classified - * failure (`'cancelled'` is passed through so the app can stay silent on a user cancel), and - * `finally` always. The kit swallows none of these errors. - */ -interface KitSocialHooks { - before?: () => void | Promise; - success?: (info: Info) => void | Promise; - error?: (category: KitOAuthErrorCategory, error: unknown) => void | Promise; - finally?: () => void | Promise; -} - -/** Options for {@link kitFacebookLogin}. */ -export type KitFacebookLoginOptions = KitOAuthMode & - KitSocialHooks<{ accessToken: string; mode: KitOAuthModeName }> & { - /** Facebook permissions to request. */ - permissions: string[]; - }; - -/** Options for {@link kitAppleLogin}. */ -export type KitAppleLoginOptions = KitOAuthMode & KitSocialHooks<{ response: KitAppleResponse; mode: KitOAuthModeName }>; - -/** Generate a random nonce for the Facebook OIDC (Limited Login) flow. */ -const generateNonce = (length = 16): string => { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let nonce = ''; - for (let i = 0; i < length; i++) { - nonce += charset[Math.floor(Math.random() * charset.length)]; - } - return nonce; -}; - -const classifyOAuthError = (e: unknown): KitOAuthErrorCategory => { - const code = (e as { code?: string } | undefined)?.code; - if (code === 'auth/credential-already-in-use') { - return 'already-in-use'; - } - if (code === 'auth/user-cancelled' || code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') { - return 'cancelled'; - } - return 'other'; -}; - -type Settled = { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown }; - -/** Convert a possibly synchronously-throwing Promise producer into an explicit result. */ -const settle = (operation: () => Promise): Promise> => { - const execute = async (): Promise => operation(); - return execute().then( - (value) => ({ status: 'fulfilled', value }), - (reason: unknown) => ({ status: 'rejected', reason }), - ); -}; - -/** - * The shared 3-mode credential state machine (internal). - * - * @remarks - * `new` signs in with the credential, `link` links it, `credential` re-authenticates with the social - * credential and then links an email/password. Any Firebase error is classified and handed to - * `error` (returning `false`); on success the app's `success` hook (backend + feedback) runs with the - * identity payload, and it returns `true`. - */ -const applyOAuthCredential = async ( - auth: Auth, - credential: AuthCredential, - mode: KitOAuthMode, - effects: { - success: () => void | Promise; - error: (category: KitOAuthErrorCategory, error: unknown) => void | Promise; - }, -): Promise => { - const result = await settle(async () => { - if (mode.mode === 'new') { - await signInWithCredential(auth, credential); - } else { - const user = auth.currentUser; - if (!user) { - throw new Error('kit social: no signed-in user to link/re-authenticate'); - } - if (mode.mode === 'link') { - await linkWithCredential(user, credential); - } else { - await reauthenticateWithCredential(user, credential); - await linkWithCredential(user, EmailAuthProvider.credential(mode.emailLogin.email, mode.emailLogin.password)); - } - } - }); - if (result.status === 'rejected') { - await effects.error(classifyOAuthError(result.reason), result.reason); - return false; - } - await effects.success(); - return true; -}; - -/** Await one animation frame where available (iOS WebView crash workaround; no-op off-browser). */ -const nextFrame = (): Promise => - new Promise((resolve) => { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => resolve()); - } else { - resolve(); - } - }); - -/** Web plugin cancellation shape: the login promise rejects with a response whose token is null. */ -const isFacebookCancellation = (error: unknown): boolean => { - if (typeof error !== 'object' || error === null || !('accessToken' in error)) { - return false; - } - const { accessToken } = error; - if (typeof accessToken !== 'object' || accessToken === null || !('token' in accessToken)) { - return false; - } - return accessToken.token === null; -}; - -/** - * Facebook login / link, bundled: native plugin → credential → the shared 3-mode state machine. - * - * @remarks - * On iOS the credential is built from the OIDC token with a nonce (`OAuthProvider('facebook.com')`); - * elsewhere from the access token (`FacebookAuthProvider`). Returns `{ status: false }` on a - * cancelled/failed plugin login or a handled Firebase error (the app was already notified via the - * hooks). - */ -export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOptions): Promise<{ status: boolean }> => { - const execute = async (): Promise<{ status: boolean }> => { - await options.before?.(); - const nonce = generateNonce(); - const login = await settle(() => FacebookLogin.login({ permissions: options.permissions, nonce })); - await nextFrame(); - if (login.status === 'rejected') { - if (!isFacebookCancellation(login.reason)) await options.error?.('other', login.reason); - return { status: false }; - } - const event = login.value; - if (!event?.accessToken?.token) return { status: false }; - const accessToken = event.accessToken.token; - const credential: AuthCredential = - Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios' - ? new OAuthProvider('facebook.com').credential({ rawNonce: nonce, idToken: accessToken })! - : FacebookAuthProvider.credential(accessToken); - - const status = await applyOAuthCredential(auth, credential, options, { - success: () => options.success?.({ accessToken, mode: options.mode }), - error: (category, error) => options.error?.(category, error), - }); - return { status }; - }; - return execute().finally(() => options.finally?.()); -}; - -/** - * Log out of the Facebook SDK (best-effort; errors are ignored). - * - * @remarks - * Apps that offer Facebook login typically call this alongside the Firebase sign-out, so it lives - * here to keep the `@capacitor-community/facebook-login` import out of the app. - * Skips the call when the Facebook SDK has no active session — otherwise `FB.logout()` logs - * "called without an access token" on web and native rejects for email/password users. - */ -export const kitFacebookLogout = async (): Promise => { - const session = await FacebookLogin.getCurrentAccessToken().catch(() => null); - if (!session?.accessToken?.token) { - return; - } - await FacebookLogin.logout().catch(() => undefined); -}; - -/** - * Sign in with Apple / link, bundled. Native uses the plugin; the web uses the Firebase popup. - * - * @remarks - * - **Native**: `SignInWithApple.authorize()` → `OAuthProvider('apple.com')` credential → the shared - * 3-mode state machine. - * - **Web**: `signInWithPopup` / `linkWithPopup` (with `email`/`name` scopes), or, for `credential`, - * `reauthenticateWithPopup` then link the email/password. The identity payload for the backend is - * synthesized from the popup result. - * - * Every failure path (including popup errors) is routed through `onError`. - */ -export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): Promise<{ status: boolean }> => { - const execute = async (): Promise<{ status: boolean }> => { - await options.before?.(); - if (Capacitor.isNativePlatform()) { - const authorize = await SignInWithApple.authorize().catch(() => undefined); - if (!authorize) return { status: false }; - const r = authorize.response; - const response: KitAppleResponse = { - user: r.user ?? null, - email: r.email ?? null, - givenName: r.givenName ?? null, - familyName: r.familyName ?? null, - identityToken: r.identityToken ?? null, - authorizationCode: r.authorizationCode ?? null, - }; - const credential = new OAuthProvider('apple.com').credential({ idToken: response.identityToken ?? undefined })!; - const status = await applyOAuthCredential(auth, credential, options, { - success: () => options.success?.({ response, mode: options.mode }), - error: (category, error) => options.error?.(category, error), - }); - return { status }; - } - - // Web: the popup performs the sign-in/link itself. - const provider = new OAuthProvider('apple.com'); - provider.addScope('email'); - provider.addScope('name'); - - if (options.mode === 'credential') { - const operation = await settle(async () => { - const user = requireUser(auth); - await reauthenticateWithPopup(user, provider); - await linkWithCredential(user, EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password)); - }); - if (operation.status === 'rejected') { - await options.error?.(classifyOAuthError(operation.reason), operation.reason); - return { status: false }; - } - await options.success?.({ response: emptyAppleResponse(), mode: 'credential' }); - return { status: true }; - } - - const popup = await settle(() => - options.mode === 'new' ? signInWithPopup(auth, provider) : linkWithPopup(requireUser(auth), provider), - ); - if (popup.status === 'rejected') { - await options.error?.(classifyOAuthError(popup.reason), popup.reason); - return { status: false }; - } - const result = popup.value; - const credential = OAuthProvider.credentialFromResult(result); - const response: KitAppleResponse = { - ...emptyAppleResponse(), - email: result.user?.email ?? null, - identityToken: credential?.idToken ?? null, - authorizationCode: credential?.accessToken ?? null, - }; - await options.success?.({ response, mode: options.mode }); - return { status: true }; - }; - return execute().finally(() => options.finally?.()); -}; - -const emptyAppleResponse = (): KitAppleResponse => ({ - user: null, - email: null, - givenName: null, - familyName: null, - identityToken: null, - authorizationCode: null, -}); - -const requireUser = (auth: Auth) => { - const user = auth.currentUser; - if (!user) { - throw new Error('kit social: no signed-in user to link'); - } - return user; -}; +import { kitAppleLogin as appleLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; +import type { + KitAppleLoginOptions as AppleLoginOptions, + KitAppleResponse as AppleResponse, + KitOAuthErrorCategory as OAuthErrorCategory, + KitOAuthMode as OAuthMode, + KitOAuthModeName as OAuthModeName, +} from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; +import { kitFacebookLogin as facebookLogin, kitFacebookLogout as facebookLogout } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; +import type { KitFacebookLoginOptions as FacebookLoginOptions } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; + +/** @deprecated Import kitAppleLogin from @rdlabo/ionic-angular-kit/auth-firebase/apple. Kept for backwards compatibility. */ +export const kitAppleLogin: typeof appleLogin = appleLogin; +/** @deprecated Import kitFacebookLogin from @rdlabo/ionic-angular-kit/auth-firebase/facebook. Kept for backwards compatibility. */ +export const kitFacebookLogin: typeof facebookLogin = facebookLogin; +/** @deprecated Import kitFacebookLogout from @rdlabo/ionic-angular-kit/auth-firebase/facebook. Kept for backwards compatibility. */ +export const kitFacebookLogout: typeof facebookLogout = facebookLogout; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/apple. */ +export type KitAppleLoginOptions = AppleLoginOptions; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/apple. */ +export type KitAppleResponse = AppleResponse; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/apple or /facebook. */ +export type KitOAuthErrorCategory = OAuthErrorCategory; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/apple or /facebook. */ +export type KitOAuthMode = OAuthMode; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/apple or /facebook. */ +export type KitOAuthModeName = OAuthModeName; +/** @deprecated Import this type from @rdlabo/ionic-angular-kit/auth-firebase/facebook. */ +export type KitFacebookLoginOptions = FacebookLoginOptions; diff --git a/projects/kit/docs/optional-features.md b/projects/kit/docs/optional-features.md index 3a10e685..d8435866 100644 --- a/projects/kit/docs/optional-features.md +++ b/projects/kit/docs/optional-features.md @@ -57,7 +57,7 @@ The `/printer` entry point contains pure helpers for DOM-to-PNG rendering, image The `/auth-firebase` entry point initializes `firebase/auth` through `provideKitFirebase()` and exposes `KIT_FIREBASE_AUTH` plus flow helpers such as `kitSignIn`, `kitSignUp`, `kitSignOut`, `kitResolveAuthStatus`, and `kitReauthWithRetry`. -The kit performs no UI. Hooks carry loading, navigation, and error presentation back to the application. Social providers are isolated further under `/auth-firebase/social`. +The kit performs no UI. Hooks carry loading, navigation, and error presentation back to the application. Social providers have separate `/auth-firebase/apple`, `/auth-firebase/facebook`, and `/auth-firebase/google` entry points, so each provider needs only its own optional plugin. The combined `/auth-firebase/social` entry point is **deprecated and retained for backwards compatibility**; migrate its Apple imports to `/apple` and its Facebook imports to `/facebook`. ## Live Update diff --git a/projects/kit/tsconfig.spec.json b/projects/kit/tsconfig.spec.json index 29203c47..96d7b5a9 100644 --- a/projects/kit/tsconfig.spec.json +++ b/projects/kit/tsconfig.spec.json @@ -18,6 +18,8 @@ "offline/src/**/*.spec.ts", "auth-firebase/src/**/*.spec.ts", "auth-firebase/social/src/**/*.spec.ts", - "auth-firebase/google/src/**/*.spec.ts" + "auth-firebase/google/src/**/*.spec.ts", + "auth-firebase/apple/src/**/*.spec.ts", + "auth-firebase/facebook/src/**/*.spec.ts" ] } diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs index d8faeaf9..012789a4 100644 --- a/scripts/test-package-consumer.mjs +++ b/scripts/test-package-consumer.mjs @@ -10,6 +10,10 @@ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'ionic-angular-package-con const packageProjects = ['kit', 'photo-editor', 'scroll-header', 'scroll-strategies']; const commandEnvironment = { ...process.env, npm_config_cache: join(temporaryDirectory, 'npm-cache') }; const installedPackages = new Map(); +const kitPackageName = '@rdlabo/ionic-angular-kit'; +const appleSignInPlugin = '@capacitor-community/apple-sign-in'; +const facebookLoginPlugin = '@capacitor-community/facebook-login'; +const googleSignInPlugin = '@capawesome/capacitor-google-sign-in'; const installPackedPackage = (project) => { const distribution = join(workspace, 'dist', project); @@ -39,6 +43,12 @@ const exportedModule = (packageName, exportName) => { return resolve(target, modulePath); }; +const kitExportName = (importPath) => { + if (importPath === kitPackageName) return '.'; + if (importPath.startsWith(`${kitPackageName}/`)) return `./${importPath.slice(kitPackageName.length + 1)}`; + return null; +}; + const bundlePhotoSurface = async (exportName, forbiddenPackages, requiredImports = []) => { const packageName = '@rdlabo/ionic-angular-photo-editor'; const primaryModule = exportedModule(packageName, '.'); @@ -78,10 +88,42 @@ const bundlePhotoSurface = async (exportName, forbiddenPackages, requiredImports assert.deepEqual([...observedImports].sort(), [...requiredImports].sort(), `${exportName} must use literal adapter imports`); }; +const bundleAuthFirebaseSurface = async (exportName, forbiddenPackages) => { + await build({ + entryPoints: [exportedModule(kitPackageName, exportName)], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + nodePaths: [join(workspace, 'node_modules')], + logLevel: 'silent', + plugins: [ + { + name: 'auth-firebase-provider-isolation', + setup(buildContext) { + buildContext.onResolve({ filter: /.*/ }, ({ path }) => { + if (forbiddenPackages.includes(path)) { + throw new Error(`${exportName} has a static dependency on optional peer ${path}`); + } + const kitExport = kitExportName(path); + if (kitExport !== null) { + return { path: exportedModule(kitPackageName, kitExport) }; + } + if (!path.startsWith('.') && !path.startsWith('/')) { + return { path, external: true }; + } + return undefined; + }); + }, + }, + ], + }); +}; + try { packageProjects.forEach(installPackedPackage); - const kitPackage = installedPackages.get('@rdlabo/ionic-angular-kit'); + const kitPackage = installedPackages.get(kitPackageName); const offlineTypesPath = kitPackage.manifest.exports?.['./offline']?.types; assert.equal(typeof offlineTypesPath, 'string', 'Kit is missing offline declaration export'); const offlineDeclaration = readFileSync(resolve(kitPackage.target, offlineTypesPath), 'utf8'); @@ -103,10 +145,18 @@ try { await bundlePhotoSurface('./editor/tui', ['@capacitor/camera', 'swiper'], ['tui-image-editor']); await bundlePhotoSurface('./file/capacitor', ['swiper', 'tui-image-editor'], ['@capacitor/camera']); + await bundleAuthFirebaseSurface('./auth-firebase/apple', [facebookLoginPlugin, googleSignInPlugin]); + await bundleAuthFirebaseSurface('./auth-firebase/facebook', [appleSignInPlugin, googleSignInPlugin]); + await bundleAuthFirebaseSurface('./auth-firebase/google', [appleSignInPlugin, facebookLoginPlugin]); + writeFileSync( join(temporaryDirectory, 'consumer.ts'), `import { type KitAuthInputMode } from '@rdlabo/ionic-angular-kit'; import { provideKitAppUpdate, type KitAppUpdateOptions, type KitAppUpdateProviderOptions } from '@rdlabo/ionic-angular-kit/app-update'; +import { kitAppleLogin, type KitAppleResponse } from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; +import { kitAppleLogin as legacyAppleLogin, type KitAppleResponse as LegacyAppleResponse } from '@rdlabo/ionic-angular-kit/auth-firebase/social'; +import { kitFacebookLogin, kitFacebookLogout } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; +import { kitGoogleLogin, kitGoogleLogout } from '@rdlabo/ionic-angular-kit/auth-firebase/google'; import { KitIonicFormField, provideKitIonicSignalForms } from '@rdlabo/ionic-angular-kit/forms'; import { providePhotoEditor, type PhotoEditorProps, type PhotoEditorResult, type PhotoViewerProps, type PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor'; import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; @@ -133,8 +183,29 @@ const invalidConfirmUpdate: KitAppUpdateProviderOptions = { strategy: 'confirm' // @ts-expect-error only confirm accepts a prompt const invalidBackgroundUpdate: KitAppUpdateProviderOptions = { strategy: 'background', promptForUpdate: async () => false }; const updateProviders = [provideKitAppUpdate(backgroundUpdate), provideKitAppUpdate(extendedBackgroundUpdate), provideKitAppUpdate(new ConsumerUpdateClass()), provideKitAppUpdate(confirmUpdate)]; -const symbols = [KitIonicFormField, provideKitIonicSignalForms, provideKitAppUpdate, providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; -void [mode, viewerProps, editorResult, viewerResult, photoProviders, invalidConfirmUpdate, invalidBackgroundUpdate, updateProviders, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; +const nativeAppleResponse: KitAppleResponse = { + user: null, + email: null, + givenName: null, + familyName: null, + identityToken: 'id-token', + authorizationCode: 'native-authorization-code', + accessToken: null, +}; +// Existing callers constructing the old response shape must remain source-compatible. +const legacyAppleResponse: LegacyAppleResponse = { user: null, email: null, givenName: null, familyName: null, identityToken: null, authorizationCode: null }; +const compatibleAppleLogin: typeof kitAppleLogin = legacyAppleLogin; +const webAppleResponse: KitAppleResponse = { + user: null, + email: null, + givenName: null, + familyName: null, + identityToken: 'id-token', + authorizationCode: null, + accessToken: 'web-access-token', +}; +const symbols = [KitIonicFormField, provideKitIonicSignalForms, provideKitAppUpdate, kitAppleLogin, kitFacebookLogin, kitFacebookLogout, kitGoogleLogin, kitGoogleLogout, providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; +void [mode, viewerProps, editorResult, viewerResult, photoProviders, invalidConfirmUpdate, invalidBackgroundUpdate, updateProviders, nativeAppleResponse, webAppleResponse, legacyAppleResponse, compatibleAppleLogin, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; `, ); writeFileSync( @@ -148,6 +219,10 @@ void [mode, viewerProps, editorResult, viewerResult, photoProviders, invalidConf module: 'preserve', moduleResolution: 'bundler', lib: ['ES2022', 'DOM'], + paths: { + firebase: [join(workspace, 'node_modules/firebase')], + 'firebase/*': [join(workspace, 'node_modules/firebase/*')], + }, }, files: ['./consumer.ts'], }), diff --git a/tsconfig.json b/tsconfig.json index 82fb97ba..1750193f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,10 @@ "@rdlabo/ionic-angular-kit": ["./projects/kit/src/public-api.ts"], "@rdlabo/ionic-angular-kit/auth-firebase": ["./projects/kit/auth-firebase/src/public-api.ts"], "@rdlabo/ionic-angular-kit/auth-firebase/social": ["./projects/kit/auth-firebase/social/src/public-api.ts"], + "@rdlabo/ionic-angular-kit/auth-firebase/internal": ["./projects/kit/auth-firebase/internal/src/public-api.ts"], + "@rdlabo/ionic-angular-kit/auth-firebase/apple": ["./projects/kit/auth-firebase/apple/src/public-api.ts"], + "@rdlabo/ionic-angular-kit/auth-firebase/facebook": ["./projects/kit/auth-firebase/facebook/src/public-api.ts"], + "@rdlabo/ionic-angular-kit/auth-firebase/google": ["./projects/kit/auth-firebase/google/src/public-api.ts"], "scroll-strategies": ["./dist/scroll-strategies"], "photo-editor": ["./dist/photo-editor"], "@rdlabo/ionic-angular-photo-editor": ["./projects/photo-editor/src/public-api.ts"], From b188e91a8642853bb722f0d5ee27948a0c840845 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 8 Sep 2026 08:45:21 +0900 Subject: [PATCH 2/3] test(kit): make social frame scheduling deterministic --- projects/kit/auth-firebase/social/src/kit-social.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/kit/auth-firebase/social/src/kit-social.spec.ts b/projects/kit/auth-firebase/social/src/kit-social.spec.ts index cdc2ad71..b88a7df3 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.spec.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.spec.ts @@ -78,6 +78,11 @@ const hooks = () => ({ }); beforeEach(() => { + // Exercise the frame boundary without depending on jsdom's display scheduler in CI. + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + queueMicrotask(() => callback(0)); + return 0; + }); isNativePlatform.mockReturnValue(true); getPlatform.mockReturnValue('android'); }); From ea88e47930a9a639ea762019b8bebc1df787c29d Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 8 Sep 2026 08:56:48 +0900 Subject: [PATCH 3/3] fix(kit): serialize social flows and migrate Apple to Capawesome --- package-lock.json | 49 +-- package.json | 4 +- .../kit/auth-firebase/apple/src/kit-apple.ts | 40 +- .../facebook/src/kit-facebook.ts | 22 +- .../auth-firebase/google/src/kit-google.ts | 12 +- .../auth-firebase/internal/src/kit-oauth.ts | 32 ++ .../social/src/kit-social-concurrency.spec.ts | 408 ++++++++++++++++++ .../social/src/kit-social.spec.ts | 38 +- .../auth-firebase/social/src/public-api.ts | 2 +- projects/kit/docs/optional-features.md | 12 + projects/kit/package.json | 10 +- scripts/test-package-consumer.mjs | 2 +- 12 files changed, 548 insertions(+), 83 deletions(-) create mode 100644 projects/kit/auth-firebase/social/src/kit-social-concurrency.spec.ts diff --git a/package-lock.json b/package-lock.json index 30704b3d..ff9ece69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,6 @@ "@angular/cli": "^22.1.0", "@angular/compiler-cli": "^22.1.0", "@angular/service-worker": "^22.1.0", - "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", "@capacitor/cli": ">=7.0.0 <9.0.0", @@ -44,6 +43,7 @@ "@capacitor/network": "^8.0.1", "@capacitor/preferences": "^8.0.1", "@capacitor/status-bar": "^8.0.2", + "@capawesome/capacitor-apple-sign-in": "^0.1.4", "@capawesome/capacitor-google-sign-in": "^0.1.4", "@capawesome/capacitor-live-update": "^8.3.0", "@eslint/js": "^9.39.4", @@ -2553,19 +2553,6 @@ "types-pkg-json": "^1.1.0" } }, - "node_modules/@capacitor-community/apple-sign-in": { - "version": "8.0.0-beta.0", - "resolved": "git+ssh://git@github.com/rdlabo/apple-sign-in.git#8a7119b243f64facfee9519a9e5072634ccb5dbe", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/scriptjs": "0.0.2", - "scriptjs": "^2.5.9" - }, - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor-community/facebook-login": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@capacitor-community/facebook-login/-/facebook-login-8.0.0.tgz", @@ -2774,6 +2761,26 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capawesome/capacitor-apple-sign-in": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@capawesome/capacitor-apple-sign-in/-/capacitor-apple-sign-in-0.1.4.tgz", + "integrity": "sha512-B6aEtGCJ4WDe71LFdBkSeHQQ1aluJCNFHE7oMAZlB1f37oD0i6mYEoWMCKtAY4RGmR//el/SY8Sed9JIZ1+eXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capawesome/capacitor-google-sign-in": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.4.tgz", @@ -8998,13 +9005,6 @@ "dev": true, "license": "MIT" }, - "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==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -22332,13 +22332,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/scriptjs": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz", - "integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==", - "dev": true, - "license": "MIT" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index 6a94b2ad..6bf5d5fd 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "@angular/cli": "^22.1.0", "@angular/compiler-cli": "^22.1.0", "@angular/service-worker": "^22.1.0", - "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", "@capacitor/cli": ">=7.0.0 <9.0.0", @@ -108,7 +107,8 @@ "typescript": "~6.0.0", "typescript-eslint": "^8.61.1", "vitest": "^4.0.0", - "write-pkg": "^7.0.0" + "write-pkg": "^7.0.0", + "@capawesome/capacitor-apple-sign-in": "^0.1.4" }, "peerDependencies": { "@angular/cdk": ">=21.0.0 <23.0.0" diff --git a/projects/kit/auth-firebase/apple/src/kit-apple.ts b/projects/kit/auth-firebase/apple/src/kit-apple.ts index e513b304..8841a7b6 100644 --- a/projects/kit/auth-firebase/apple/src/kit-apple.ts +++ b/projects/kit/auth-firebase/apple/src/kit-apple.ts @@ -8,8 +8,14 @@ import { signInWithPopup, } from 'firebase/auth'; import { Capacitor } from '@capacitor/core'; -import { SignInWithApple } from '@capacitor-community/apple-sign-in'; -import { applyOAuthCredential, assertCurrentUser, classifyOAuthError, requireUser } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import { AppleSignIn, ErrorCode, SignInScope } from '@capawesome/capacitor-apple-sign-in'; +import { + applyOAuthCredential, + assertCurrentUser, + classifyOAuthError, + requireUser, + runOAuthOperation, +} from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; import type { KitOAuthMode, KitOAuthModeName, KitSocialHooks } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; /** Apple credentials. Native authorization codes and web access tokens are distinct values. */ @@ -41,13 +47,11 @@ const emptyAppleResponse = (): KitAppleResponse => ({ }); const classifyAppleError = (error: unknown) => { - const code = (error as { code?: string | number } | null)?.code; - // ASAuthorizationError.canceled, when the native adapter preserves its code. - // Adapters that only expose a localized message must be treated as an operational error. - return code === 1001 || code === '1001' ? 'cancelled' : classifyOAuthError(error); + const code = (error as { code?: string } | null)?.code; + return code === ErrorCode.SignInCanceled ? 'cancelled' : classifyOAuthError(error); }; -/** Apple sign-in/link on iOS and web. Android is unsupported by the native Apple plugin. +/** Apple sign-in/link on iOS and web. Native Android is not configured by this helper. * Pins the user before any asynchronous work and reports SDK and app-hook failures to error. */ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): Promise<{ status: boolean }> => { @@ -59,8 +63,16 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): let user: User; if (Capacitor.isNativePlatform()) { if (Capacitor.getPlatform() !== 'ios') throw new Error('kit Apple login: native platform is not supported'); - const { response: native } = await SignInWithApple.authorize(); - response = { ...emptyAppleResponse(), ...native }; + const native = await AppleSignIn.signIn({ scopes: [SignInScope.Email, SignInScope.FullName] }); + response = { + user: native.user, + email: native.email, + givenName: native.givenName, + familyName: native.familyName, + identityToken: native.idToken, + authorizationCode: native.authorizationCode, + accessToken: null, + }; if (!response.identityToken) throw new Error('kit Apple login: identity token is missing'); const credential = new OAuthProvider('apple.com').credential({ idToken: response.identityToken }); user = await applyOAuthCredential(auth, credential, options, expectedUser); @@ -91,13 +103,5 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): await options.success?.({ response, mode: options.mode, user }); assertCurrentUser(auth, user); }; - return execute() - .then( - () => ({ status: true }), - async (error: unknown) => { - await options.error?.(classifyAppleError(error), error); - return { status: false }; - }, - ) - .finally(() => options.finally?.()); + return runOAuthOperation(auth, execute, (error) => options.error?.(classifyAppleError(error), error), options.finally); }; diff --git a/projects/kit/auth-firebase/facebook/src/kit-facebook.ts b/projects/kit/auth-firebase/facebook/src/kit-facebook.ts index abd1d7cf..a7030919 100644 --- a/projects/kit/auth-firebase/facebook/src/kit-facebook.ts +++ b/projects/kit/auth-firebase/facebook/src/kit-facebook.ts @@ -2,7 +2,12 @@ import type { Auth, User } from 'firebase/auth'; import { FacebookAuthProvider, OAuthProvider } from 'firebase/auth'; import { Capacitor } from '@capacitor/core'; import { FacebookLogin } from '@capacitor-community/facebook-login'; -import { applyOAuthCredential, assertCurrentUser, classifyOAuthError } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import { + applyOAuthCredential, + assertCurrentUser, + classifyOAuthError, + runOAuthOperation, +} from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; import type { KitOAuthMode, KitOAuthModeName, KitSocialHooks } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; /** Facebook login options. Permissions and app effects are owned by the consumer. */ @@ -54,15 +59,12 @@ export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOpti await options.success?.({ accessToken, mode: options.mode, user }); assertCurrentUser(auth, user); }; - return execute() - .then( - () => ({ status: true }), - async (error: unknown) => { - await options.error?.(isFacebookCancellation(error) ? 'cancelled' : classifyOAuthError(error), error); - return { status: false }; - }, - ) - .finally(() => options.finally?.()); + return runOAuthOperation( + auth, + execute, + (error) => options.error?.(isFacebookCancellation(error) ? 'cancelled' : classifyOAuthError(error), error), + options.finally, + ); }; /** diff --git a/projects/kit/auth-firebase/google/src/kit-google.ts b/projects/kit/auth-firebase/google/src/kit-google.ts index 7e184f57..54a7e7d8 100644 --- a/projects/kit/auth-firebase/google/src/kit-google.ts +++ b/projects/kit/auth-firebase/google/src/kit-google.ts @@ -1,6 +1,6 @@ import { ErrorCode, GoogleSignIn } from '@capawesome/capacitor-google-sign-in'; import { Capacitor } from '@capacitor/core'; -import { assertCurrentUser, classifyOAuthError, requireUser } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import { assertCurrentUser, classifyOAuthError, requireUser, runOAuthOperation } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; import type { KitOAuthErrorCategory } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; import type { Auth, User } from 'firebase/auth'; import { @@ -119,15 +119,7 @@ export const kitGoogleLogin = async (auth: Auth, options: KitGoogleLoginOptions) await options.success?.({ idToken, mode: options.mode, user }); assertCurrentUser(auth, user); }; - return execute() - .then( - () => ({ status: true }), - async (error: unknown) => { - await options.error?.(classify(error), error); - return { status: false }; - }, - ) - .finally(() => options.finally?.()); + return runOAuthOperation(auth, execute, (error) => options.error?.(classify(error), error), options.finally); }; /** Best-effort native Google SDK sign-out. */ diff --git a/projects/kit/auth-firebase/internal/src/kit-oauth.ts b/projects/kit/auth-firebase/internal/src/kit-oauth.ts index f68831e1..7c9b7e7f 100644 --- a/projects/kit/auth-firebase/internal/src/kit-oauth.ts +++ b/projects/kit/auth-firebase/internal/src/kit-oauth.ts @@ -18,6 +18,38 @@ export interface KitSocialHooks { finally?: () => void | Promise; } +const activeOperations = new WeakSet(); + +/** @internal Reject overlapping kit social flows for one Auth, including across providers. + * Holds the guard through error/finally hooks and releases it even when a hook rejects. + * Direct Firebase operations outside these helpers must be coordinated by the application. + */ +export const runOAuthOperation = ( + auth: Auth, + operation: () => Promise, + onError: (error: unknown) => void | Promise, + onFinally?: () => void | Promise, +): Promise<{ status: boolean }> => { + const busy = activeOperations.has(auth); + if (!busy) activeOperations.add(auth); + const execute = async (): Promise => { + if (busy) throw new Error('kit social: authentication already in progress'); + await operation(); + }; + return execute() + .then( + () => ({ status: true }), + async (error: unknown) => { + await onError(error); + return { status: false }; + }, + ) + .finally(() => onFinally?.()) + .finally(() => { + if (!busy) activeOperations.delete(auth); + }); +}; + /** @internal Classify Firebase SDK failures without inspecting localized messages. */ export const classifyOAuthError = (error: unknown): KitOAuthErrorCategory => { const code = (error as { code?: string | number } | null)?.code; diff --git a/projects/kit/auth-firebase/social/src/kit-social-concurrency.spec.ts b/projects/kit/auth-firebase/social/src/kit-social-concurrency.spec.ts new file mode 100644 index 00000000..02807abe --- /dev/null +++ b/projects/kit/auth-firebase/social/src/kit-social-concurrency.spec.ts @@ -0,0 +1,408 @@ +import { kitAppleLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; +import { kitFacebookLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; +import { kitGoogleLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/google'; +import { runOAuthOperation } from '@rdlabo/ionic-angular-kit/auth-firebase/internal'; +import type { Auth, User } from 'firebase/auth'; + +const signInWithCredential = vi.fn(); +const linkWithCredential = vi.fn(); +const reauthenticateWithCredential = vi.fn(); +const signInWithPopup = vi.fn(); +const linkWithPopup = vi.fn(); +const reauthenticateWithPopup = vi.fn(); + +const isNativePlatform = vi.fn(); +const getPlatform = vi.fn(); +const facebookLogin = vi.fn(); +const facebookLogout = vi.fn(); +const facebookGetCurrentAccessToken = vi.fn(); +const appleAuthorize = vi.fn(); +const googleInitialize = vi.fn(); +const googleSignIn = vi.fn(); +const googleSignOut = vi.fn(); + +vi.mock('firebase/auth', () => ({ + signInWithCredential: (...a: unknown[]) => signInWithCredential(...a), + linkWithCredential: (...a: unknown[]) => linkWithCredential(...a), + reauthenticateWithCredential: (...a: unknown[]) => reauthenticateWithCredential(...a), + signInWithPopup: (...a: unknown[]) => signInWithPopup(...a), + linkWithPopup: (...a: unknown[]) => linkWithPopup(...a), + reauthenticateWithPopup: (...a: unknown[]) => reauthenticateWithPopup(...a), + EmailAuthProvider: { credential: (email: string, password: string) => ({ email, password }) }, + FacebookAuthProvider: { credential: (t: string) => ({ fb: t }) }, + GoogleAuthProvider: class { + static credential(idToken: string) { + return { idToken }; + } + static credentialFromResult(result: { idToken?: string }) { + return result.idToken ? { idToken: result.idToken } : null; + } + }, + OAuthProvider: class { + id: string; + constructor(id: string) { + this.id = id; + } + credential(o: unknown) { + return { oauth: o, providerId: this.id }; + } + addScope() {} + static credentialFromResult() { + return { idToken: 'id-token', accessToken: 'access-token' }; + } + }, +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { isNativePlatform: () => isNativePlatform(), getPlatform: () => getPlatform() }, +})); +vi.mock('@capacitor-community/facebook-login', () => ({ + FacebookLogin: { + login: (...a: unknown[]) => facebookLogin(...a), + logout: (...a: unknown[]) => facebookLogout(...a), + getCurrentAccessToken: (...a: unknown[]) => facebookGetCurrentAccessToken(...a), + }, +})); +vi.mock('@capawesome/capacitor-apple-sign-in', () => ({ + AppleSignIn: { signIn: (...a: unknown[]) => appleAuthorize(...a) }, + ErrorCode: { SignInCanceled: 'SIGN_IN_CANCELED' }, + SignInScope: { Email: 'EMAIL', FullName: 'FULL_NAME' }, +})); +vi.mock('@capawesome/capacitor-google-sign-in', () => ({ + ErrorCode: { SignInCanceled: 'SIGN_IN_CANCELED' }, + GoogleSignIn: { + initialize: (...a: unknown[]) => googleInitialize(...a), + signIn: (...a: unknown[]) => googleSignIn(...a), + signOut: (...a: unknown[]) => googleSignOut(...a), + }, +})); + +type ProviderName = 'apple' | 'facebook' | 'google'; +type OAuthModeName = 'new' | 'link' | 'credential' | 'reauthenticate'; + +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +const authWith = (currentUser: unknown): Auth => ({ currentUser }) as unknown as Auth; +const setCurrentUser = (auth: Auth, user: User | null) => { + (auth as unknown as { currentUser: User | null }).currentUser = user; +}; + +const hooks = () => ({ + before: vi.fn().mockResolvedValue(undefined), + success: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + finally: vi.fn().mockResolvedValue(undefined), +}); + +const armNativePlugin = (provider: ProviderName) => { + if (provider === 'apple') { + appleAuthorize.mockResolvedValueOnce({ + idToken: 'apple-token', + email: 'a@b.com', + user: 'apple-user', + givenName: null, + familyName: null, + authorizationCode: 'apple-code', + }); + return; + } + if (provider === 'facebook') { + facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'fb-token' } }); + return; + } + googleInitialize.mockResolvedValue(undefined); + googleSignIn.mockResolvedValueOnce({ idToken: 'google-token' }); +}; + +const hangFirebase = (mode: OAuthModeName, auth: Auth, user: User) => { + const gate = deferred(); + const entered = deferred(); + const hold = async (value: T): Promise => { + entered.resolve(); + await gate.promise; + return value; + }; + + if (mode === 'new') { + signInWithCredential.mockImplementationOnce(async () => { + setCurrentUser(auth, user); + return hold({ user }); + }); + } else if (mode === 'link') { + linkWithCredential.mockImplementationOnce(async () => hold({})); + } else { + reauthenticateWithCredential.mockImplementationOnce(async () => hold({ user })); + if (mode === 'credential') linkWithCredential.mockResolvedValueOnce({}); + } + + return { entered: entered.promise, release: () => gate.resolve() }; +}; + +const pluginSnapshot = () => ({ + apple: appleAuthorize.mock.calls.length, + facebook: facebookLogin.mock.calls.length, + googleSignIn: googleSignIn.mock.calls.length, + googleInitialize: googleInitialize.mock.calls.length, + signIn: signInWithCredential.mock.calls.length, + link: linkWithCredential.mock.calls.length, + reauth: reauthenticateWithCredential.mock.calls.length, +}); + +const expectNoSecondEffects = (provider: ProviderName, before: ReturnType, h: ReturnType) => { + expect(h.before).not.toHaveBeenCalled(); + expect(h.success).not.toHaveBeenCalled(); + const after = pluginSnapshot(); + if (provider === 'apple') expect(after.apple).toBe(before.apple); + if (provider === 'facebook') expect(after.facebook).toBe(before.facebook); + if (provider === 'google') { + expect(after.googleSignIn).toBe(before.googleSignIn); + expect(after.googleInitialize).toBe(before.googleInitialize); + } + expect(after.signIn).toBe(before.signIn); + expect(after.link).toBe(before.link); + expect(after.reauth).toBe(before.reauth); +}; + +const startLogin = (provider: ProviderName, auth: Auth, mode: OAuthModeName, h: ReturnType) => { + if (provider === 'apple') { + if (mode === 'reauthenticate') throw new Error('Apple has no reauthenticate mode'); + if (mode === 'credential') return kitAppleLogin(auth, { mode: 'credential', emailLogin: { email: 'e@x.com', password: 'pw' }, ...h }); + return kitAppleLogin(auth, { mode, ...h }); + } + if (provider === 'facebook') { + if (mode === 'reauthenticate') throw new Error('Facebook has no reauthenticate mode'); + if (mode === 'credential') { + return kitFacebookLogin(auth, { mode: 'credential', emailLogin: { email: 'e@x.com', password: 'pw' }, permissions: [], ...h }); + } + return kitFacebookLogin(auth, { mode, permissions: [], ...h }); + } + if (mode === 'credential') { + return kitGoogleLogin(auth, { + mode: 'credential', + clientId: 'concurrency-client', + emailLogin: { email: 'e@x.com', password: 'pw' }, + ...h, + }); + } + return kitGoogleLogin(auth, { mode, clientId: 'concurrency-client', ...h }); +}; + +beforeEach(() => { + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + queueMicrotask(() => callback(0)); + return 0; + }); + isNativePlatform.mockReturnValue(true); + getPlatform.mockReturnValue('ios'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +const crossProviderPairs: { first: ProviderName; second: ProviderName }[] = [ + { first: 'apple', second: 'google' }, + { first: 'google', second: 'facebook' }, + { first: 'facebook', second: 'apple' }, +]; + +const sharedModes: OAuthModeName[] = ['new', 'link', 'credential']; + +describe('cross-provider concurrency through public entrypoints', () => { + it.each( + crossProviderPairs.flatMap(({ first, second }) => + sharedModes.map((mode) => ({ first, second, mode, label: `${first}(${mode}) → ${second}` })), + ), + )('blocks $label while the first Firebase credential call is pending', async ({ first, second, mode }) => { + const user = { uid: 'held' } as User; + const auth = authWith(mode === 'new' ? null : user); + armNativePlugin(first); + const { entered, release } = hangFirebase(mode, auth, user); + const firstHooks = hooks(); + const firstPromise = startLogin(first, auth, mode, firstHooks); + + await entered; + const beforeSecond = pluginSnapshot(); + const secondHooks = hooks(); + const secondResult = await startLogin(second, auth, 'new', secondHooks); + + expect(secondResult).toEqual({ status: false }); + expect(secondHooks.error).toHaveBeenCalledWith( + 'other', + expect.objectContaining({ message: 'kit social: authentication already in progress' }), + ); + expectNoSecondEffects(second, beforeSecond, secondHooks); + + release(); + await expect(firstPromise).resolves.toEqual({ status: true }); + expect(firstHooks.error).not.toHaveBeenCalled(); + expect(firstHooks.finally).toHaveBeenCalledOnce(); + }); + + it.each(crossProviderPairs.filter(({ first }) => first === 'google'))( + 'blocks $second while google(reauthenticate) is pending on the same Auth', + async ({ second }) => { + const user = { uid: 'reauth' } as User; + const auth = authWith(user); + armNativePlugin('google'); + const { entered, release } = hangFirebase('reauthenticate', auth, user); + const firstHooks = hooks(); + const firstPromise = startLogin('google', auth, 'reauthenticate', firstHooks); + + await entered; + const beforeSecond = pluginSnapshot(); + const secondHooks = hooks(); + const secondResult = await startLogin(second, auth, 'new', secondHooks); + + expect(secondResult).toEqual({ status: false }); + expect(secondHooks.error).toHaveBeenCalledWith( + 'other', + expect.objectContaining({ message: 'kit social: authentication already in progress' }), + ); + expectNoSecondEffects(second, beforeSecond, secondHooks); + + release(); + await expect(firstPromise).resolves.toEqual({ status: true }); + }, + ); +}); + +describe('Auth instance isolation and reuse', () => { + it('allows overlapping flows on independent Auth instances', async () => { + const userA = { uid: 'a' } as User; + const userB = { uid: 'b' } as User; + const authA = authWith(null); + const authB = authWith(null); + armNativePlugin('apple'); + armNativePlugin('google'); + const hangA = hangFirebase('new', authA, userA); + const hangB = hangFirebase('new', authB, userB); + + const first = startLogin('apple', authA, 'new', hooks()); + const second = startLogin('google', authB, 'new', hooks()); + await Promise.all([hangA.entered, hangB.entered]); + + hangA.release(); + hangB.release(); + await expect(Promise.all([first, second])).resolves.toEqual([{ status: true }, { status: true }]); + }); + + it('reuses the same Auth after the first operation settles', async () => { + const user = { uid: 'reuse' } as User; + const auth = authWith(null); + armNativePlugin('facebook'); + signInWithCredential.mockImplementationOnce(async () => { + setCurrentUser(auth, user); + return { user }; + }); + await expect(startLogin('facebook', auth, 'new', hooks())).resolves.toEqual({ status: true }); + + armNativePlugin('apple'); + signInWithCredential.mockImplementationOnce(async () => { + setCurrentUser(auth, user); + return { user }; + }); + await expect(startLogin('apple', auth, 'new', hooks())).resolves.toEqual({ status: true }); + }); +}); + +describe('runOAuthOperation guard lifecycle', () => { + it('holds the guard while the error hook is pending', async () => { + const auth = authWith(null); + const gate = deferred(); + const entered = deferred(); + const first = runOAuthOperation( + auth, + async () => { + throw new Error('operation failed'); + }, + async () => { + entered.resolve(); + await gate.promise; + }, + ); + + await entered.promise; + const operation = vi.fn(); + await expect(runOAuthOperation(auth, operation, () => undefined)).resolves.toEqual({ status: false }); + expect(operation).not.toHaveBeenCalled(); + + gate.resolve(); + await expect(first).resolves.toEqual({ status: false }); + }); + + it('holds the guard while the finally hook is pending', async () => { + const auth = authWith(null); + const gate = deferred(); + const entered = deferred(); + const first = runOAuthOperation( + auth, + async () => undefined, + () => undefined, + async () => { + entered.resolve(); + await gate.promise; + }, + ); + + await entered.promise; + const operation = vi.fn(); + await expect(runOAuthOperation(auth, operation, () => undefined)).resolves.toEqual({ status: false }); + expect(operation).not.toHaveBeenCalled(); + + gate.resolve(); + await expect(first).resolves.toEqual({ status: true }); + }); + + it('releases the guard even when the error hook throws', async () => { + const auth = authWith(null); + await expect( + runOAuthOperation( + auth, + async () => { + throw new Error('operation failed'); + }, + async () => { + throw new Error('error hook failed'); + }, + ), + ).rejects.toThrow('error hook failed'); + + await expect( + runOAuthOperation( + auth, + async () => undefined, + () => undefined, + ), + ).resolves.toEqual({ status: true }); + }); + + it('releases the guard even when the finally hook throws', async () => { + const auth = authWith(null); + await expect( + runOAuthOperation( + auth, + async () => undefined, + () => undefined, + async () => { + throw new Error('finally hook failed'); + }, + ), + ).rejects.toThrow('finally hook failed'); + + await expect( + runOAuthOperation( + auth, + async () => undefined, + () => undefined, + ), + ).resolves.toEqual({ status: true }); + }); +}); diff --git a/projects/kit/auth-firebase/social/src/kit-social.spec.ts b/projects/kit/auth-firebase/social/src/kit-social.spec.ts index b88a7df3..3bd422fe 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.spec.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.spec.ts @@ -1,5 +1,8 @@ import { kitAppleLogin as appleLogin } from '@rdlabo/ionic-angular-kit/auth-firebase/apple'; -import { kitFacebookLogin as facebookFlow, kitFacebookLogout as facebookLogoutFlow } from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; +import { + kitFacebookLogin as facebookFlow, + kitFacebookLogout as facebookLogoutFlow, +} from '@rdlabo/ionic-angular-kit/auth-firebase/facebook'; import type { Auth, User } from 'firebase/auth'; // Compatibility entrypoint: re-exports apple/facebook (keep exercised). import { kitAppleLogin, kitFacebookLogin, kitFacebookLogout } from './kit-social'; @@ -52,8 +55,10 @@ vi.mock('@capacitor-community/facebook-login', () => ({ getCurrentAccessToken: (...a: unknown[]) => facebookGetCurrentAccessToken(...a), }, })); -vi.mock('@capacitor-community/apple-sign-in', () => ({ - SignInWithApple: { authorize: (...a: unknown[]) => appleAuthorize(...a) }, +vi.mock('@capawesome/capacitor-apple-sign-in', () => ({ + AppleSignIn: { signIn: (...a: unknown[]) => appleAuthorize(...a) }, + ErrorCode: { SignInCanceled: 'SIGN_IN_CANCELED' }, + SignInScope: { Email: 'EMAIL', FullName: 'FULL_NAME' }, })); const fbError = (code: string) => Object.assign(new Error(code), { code }); @@ -279,13 +284,29 @@ describe('kitAppleLogin', () => { getPlatform.mockReturnValue('ios'); const user = { uid: 'apple' } as User; const auth = authWith(null); - appleAuthorize.mockResolvedValueOnce({ response: { identityToken: 'it', email: 'a@b.com' } }); + appleAuthorize.mockResolvedValueOnce({ + idToken: 'it', + email: 'a@b.com', + user: 'apple-user', + givenName: null, + familyName: null, + authorizationCode: 'apple-code', + }); mockSignInSuccess(auth, user); const h = hooks(); const res = await kitAppleLogin(auth, { mode: 'new', ...h }); expect(res).toEqual({ status: true }); + expect(appleAuthorize).toHaveBeenCalledWith({ scopes: ['EMAIL', 'FULL_NAME'] }); expect(h.success).toHaveBeenCalledWith({ - response: expect.objectContaining({ identityToken: 'it', email: 'a@b.com' }), + response: { + user: 'apple-user', + identityToken: 'it', + authorizationCode: 'apple-code', + accessToken: null, + email: 'a@b.com', + givenName: null, + familyName: null, + }, mode: 'new', user, }); @@ -315,7 +336,8 @@ describe('kitAppleLogin', () => { expect(signInWithCredential).not.toHaveBeenCalled(); }); - it.each([1001, '1001'])('native: classifies ASAuthorizationError.canceled (%s) as cancelled', async (code) => { + it('native: classifies Capawesome sign-in cancellation', async () => { + const code = 'SIGN_IN_CANCELED'; isNativePlatform.mockReturnValue(true); getPlatform.mockReturnValue('ios'); const cancelled = Object.assign(new Error('canceled'), { code }); @@ -334,7 +356,7 @@ describe('kitAppleLogin', () => { const auth = authWith(original); appleAuthorize.mockImplementationOnce(async () => { setCurrentUser(auth, { uid: 'switched' } as User); - return { response: { identityToken: 'it', email: 'a@b.com' } }; + return { idToken: 'it', email: 'a@b.com', user: 'apple-user', givenName: null, familyName: null, authorizationCode: 'apple-code' }; }); const h = hooks(); @@ -408,7 +430,7 @@ describe('Apple credential identity boundary', () => { const original = { uid: 'original' } as User; const auth = authWith(original); const changed = { uid: 'changed' } as User; - appleAuthorize.mockResolvedValueOnce({ response: { identityToken: 'apple-token' } }); + appleAuthorize.mockResolvedValueOnce({ idToken: 'apple-token' }); const reauthenticate = native ? reauthenticateWithCredential : reauthenticateWithPopup; reauthenticate.mockImplementationOnce(async () => { setCurrentUser(auth, changed); diff --git a/projects/kit/auth-firebase/social/src/public-api.ts b/projects/kit/auth-firebase/social/src/public-api.ts index 0d660fc6..d8036a94 100644 --- a/projects/kit/auth-firebase/social/src/public-api.ts +++ b/projects/kit/auth-firebase/social/src/public-api.ts @@ -1,5 +1,5 @@ // Social login (Facebook / Apple). Its own entry point so only apps that use it pull in the native -// plugins `@capacitor-community/facebook-login` and `@capacitor-community/apple-sign-in`; the core +// plugins `@capacitor-community/facebook-login` and `@capawesome/capacitor-apple-sign-in`; the core // `auth-firebase` entry stays free of them. // // Public surface is curated: two bundled flows plus their option types. The nonce util, the error diff --git a/projects/kit/docs/optional-features.md b/projects/kit/docs/optional-features.md index d8435866..c0894e95 100644 --- a/projects/kit/docs/optional-features.md +++ b/projects/kit/docs/optional-features.md @@ -59,6 +59,18 @@ The `/auth-firebase` entry point initializes `firebase/auth` through `provideKit The kit performs no UI. Hooks carry loading, navigation, and error presentation back to the application. Social providers have separate `/auth-firebase/apple`, `/auth-firebase/facebook`, and `/auth-firebase/google` entry points, so each provider needs only its own optional plugin. The combined `/auth-firebase/social` entry point is **deprecated and retained for backwards compatibility**; migrate its Apple imports to `/apple` and its Facebook imports to `/facebook`. +The compatibility entry point still loads both Apple and Facebook plugins. Its functions retain the same runtime identity, and existing Apple response objects remain source-compatible. + +Social helpers capture the current Firebase user before `before` runs and check that identity around asynchronous work. Success hooks receive the exact authenticated `user`; use it to bind backend requests to the same session. An overlapping Apple, Facebook, or Google flow for the same `Auth` is rejected through `error('other', error)` with `status: false`, before its `before` hook or SDK call starts. The guard stays active through cleanup. Different `Auth` instances are independent. + +Applications must also coordinate email/password authentication, sign-out, and direct Firebase calls with these flows. The guard covers kit social helpers in this JavaScript context; it cannot cancel an already-started Firebase request, roll back a completed provider link, or serialize changes from another browser tab. + +Failures in `before`, exchange, or `success` run the error hook and return `status: false`. `finally` always runs, including on overlapping calls; errors from `error` or `finally` reject the promise. Hooks own their UI cleanup. + +Apple consumers must install `@capawesome/capacitor-apple-sign-in` (`>=0.1.4 <1`) and run `npx cap sync` after replacing `@capacitor-community/apple-sign-in`. This native dependency migration also applies when using the deprecated `/social` entry point; its import and response types remain compatible. Native plugin changes require a new native binary. + +The Apple helper uses native sign-in on iOS and the Firebase popup on web. Android Service ID and redirect configuration are not exposed by this helper. Capawesome's typed `SIGN_IN_CANCELED` error is reported as cancellation; other native errors remain operational failures. The native `idToken` maps to the existing `identityToken` response field. On web, `authorizationCode` is `null`, and the popup access token is returned separately as `accessToken`. + ## Live Update `provideLiveUpdateReadiness()` from `/live-update` waits for Angular stability, the first completed route, and one animation frame before calling Capawesome `LiveUpdate.ready()`. It is a no-op on the web. diff --git a/projects/kit/package.json b/projects/kit/package.json index 1cef8bbd..2c1d330d 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -24,13 +24,13 @@ "@capacitor/status-bar": ">=7.0.0 <9.0.0", "@capacitor-community/in-app-review": ">=7.0.0 <9.0.0", "@capacitor-community/facebook-login": ">=8.0.0 <9.0.0", - "@capacitor-community/apple-sign-in": "*", "@capawesome/capacitor-google-sign-in": "^0.1.4", "@rdlabo/capacitor-brotherprint": ">=7.0.0 <9.0.0", "dom-to-image-more": "^3.0.0", "pdf-lib": "^1.17.1", "firebase": ">=11 <13", - "rxjs": "^7.8.0" + "rxjs": "^7.8.0", + "@capawesome/capacitor-apple-sign-in": ">=0.1.4 <1" }, "peerDependenciesMeta": { "@angular/service-worker": { @@ -54,9 +54,6 @@ "@capacitor-community/facebook-login": { "optional": true }, - "@capacitor-community/apple-sign-in": { - "optional": true - }, "@capawesome/capacitor-google-sign-in": { "optional": true }, @@ -68,6 +65,9 @@ }, "pdf-lib": { "optional": true + }, + "@capawesome/capacitor-apple-sign-in": { + "optional": true } }, "dependencies": { diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs index 012789a4..0c73969a 100644 --- a/scripts/test-package-consumer.mjs +++ b/scripts/test-package-consumer.mjs @@ -11,7 +11,7 @@ const packageProjects = ['kit', 'photo-editor', 'scroll-header', 'scroll-strateg const commandEnvironment = { ...process.env, npm_config_cache: join(temporaryDirectory, 'npm-cache') }; const installedPackages = new Map(); const kitPackageName = '@rdlabo/ionic-angular-kit'; -const appleSignInPlugin = '@capacitor-community/apple-sign-in'; +const appleSignInPlugin = '@capawesome/capacitor-apple-sign-in'; const facebookLoginPlugin = '@capacitor-community/facebook-login'; const googleSignInPlugin = '@capawesome/capacitor-google-sign-in';