From 89eaea57c821237dddecaa385d4b87bf113f24d6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 17:51:05 +0900 Subject: [PATCH] fix(auth): guard sign-out against newer sessions --- projects/kit/README.md | 14 +++++++- .../src/kit-firebase-auth.spec.ts | 28 ++++++++++++++++ .../auth-firebase/src/kit-firebase-auth.ts | 33 ++++++++++++++++++- projects/kit/auth-firebase/src/public-api.ts | 1 + 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index e70fe05..315ffb5 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -931,6 +931,7 @@ import { kitSignOut, kitResolveAuthStatus, kitReauthWithRetry, + type User, } from '@rdlabo/ionic-angular-kit/auth-firebase'; import { updatePassword } from 'firebase/auth'; // escape hatch for the reauth mutation @@ -956,13 +957,24 @@ export class AuthService { }).catch((e) => (this.presentError(e), false)); if (ok) this.overlay.alertClose({ header: 'Saved', message: '…' }); } + + // A delayed denial must not sign out a newer Firebase session. Capture the User that owns the + // request and pass it as expectedUser; the kit checks object identity after `before` completes + // and immediately before invoking Firebase signOut. + signOutDeniedSession(expectedUser: User) { + return kitSignOut( + this.#auth, + { before: () => this.clearDeniedSessionTransport() }, + { expectedUser }, + ); + } } ``` Surface: - **DI** — `KIT_FIREBASE_AUTH` (`InjectionToken`), `provideKitFirebase({ firebaseConfig })`, `provideKitFirebaseAnalytics()`. -- **Flow functions** (uniform hooks + no-throw null/false) — `kitSignIn`, `kitSignUp` (create + send verification), `kitSignOut`, `kitSendPasswordReset`, `kitSendEmailVerification`, `kitUnlinkProvider`. +- **Flow functions** (uniform hooks + no-throw null/false) — `kitSignIn`, `kitSignUp` (create + send verification), `kitSignOut`, `kitSendPasswordReset`, `kitSendEmailVerification`, `kitUnlinkProvider`. `kitSignOut(..., { expectedUser })` prevents an old asynchronous denial from signing out a newer Firebase User object. - **Mechanics** — `kitReauthWithRetry` (app injects `prompt` / `withLoading` / `mutate`; boolean result, non-wrong-password errors thrown), `kitResolveAuthStatus` (`'user' | 'confirm' | 'required'` from the user; social counts as verified; `allowWhen` bypass), `kitAuthState`, `kitGetIdToken`. - **Error dictionary** — `KIT_DEFAULT_AUTH_TEXT` (importable canonical constant; the kit does not present it — the app renders its own alert). - **Social** (`@rdlabo/ionic-angular-kit/auth-firebase/social`, separate nested entry to isolate the Capacitor plugins) — `kitFacebookLogin`, `kitAppleLogin`, `kitFacebookLogout`; options carry the same `{ before, success, error, finally }` hooks (`success` receives the identity payload for a backend call). diff --git a/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts b/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts index b051933..ee23d4c 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-auth.spec.ts @@ -218,6 +218,34 @@ describe('bundled email/password flows (uniform hooks + no-throw null/false)', ( expect(error).toHaveBeenCalledWith(boom); }); + it('kitSignOut skips a newer Firebase session that replaces the expected user during before', async () => { + const expectedUser = { uid: 'same-uid' }; + const newerUser = { uid: 'same-uid' }; + const auth = authWith(expectedUser); + const success = vi.fn(); + const error = vi.fn(); + const finallyHook = vi.fn(); + + const result = await kitSignOut( + auth, + { + before: async () => { + (auth as unknown as { currentUser: unknown }).currentUser = newerUser; + }, + success, + error, + finally: finallyHook, + }, + { expectedUser: expectedUser as unknown as import('firebase/auth').User }, + ); + + expect(result).toBe(false); + expect(signOut).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(finallyHook).toHaveBeenCalledOnce(); + }); + it('kitSendEmailVerification is a no-op (still true) when signed out, sends otherwise', async () => { expect(await kitSendEmailVerification(authWith(null))).toBe(true); expect(sendEmailVerification).not.toHaveBeenCalled(); diff --git a/projects/kit/auth-firebase/src/kit-firebase-auth.ts b/projects/kit/auth-firebase/src/kit-firebase-auth.ts index 94c00c5..55d799a 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-auth.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-auth.ts @@ -45,6 +45,18 @@ export interface KitAuthHooks { finally?: () => void | Promise; } +/** Optional identity guard evaluated immediately before Firebase sign-out is invoked. */ +export interface KitSignOutOptions { + /** + * Only sign out when this exact Firebase User object is still current after `before` completes. + * + * @remarks + * A different object is treated as a newer authentication session even when it has the same UID. + * A mismatch resolves `false`, skips `success` and `error`, and still runs `finally`. + */ + expectedUser?: User; +} + /** Run a value-returning op through the {@link KitAuthHooks} lifecycle; resolve `null` on failure. */ const runAuthFlow = async (op: () => Promise, hooks?: KitAuthHooks): Promise => { await hooks?.before?.(); @@ -101,7 +113,26 @@ export const kitSignUp = (auth: Auth, email: string, password: string, hooks?: K * App-specific cleanup (clearing stores, toasts, navigation, third-party logout) is the caller's, * done via the hooks — the kit only owns the Firebase op. `true` on success, `false` on failure. */ -export const kitSignOut = (auth: Auth, hooks?: KitAuthHooks): Promise => runAuthFlowVoid(() => signOut(auth), hooks); +export const kitSignOut = async ( + auth: Auth, + hooks?: KitAuthHooks, + options?: KitSignOutOptions, +): Promise => { + await hooks?.before?.(); + try { + if (options?.expectedUser !== undefined && auth.currentUser !== options.expectedUser) { + return false; + } + await signOut(auth); + await hooks?.success?.(); + return true; + } catch (error) { + await hooks?.error?.(error); + return false; + } finally { + await hooks?.finally?.(); + } +}; /** Send a password-reset email. `true` on success, `false` on failure. */ export const kitSendPasswordReset = (auth: Auth, email: string, hooks?: KitAuthHooks): Promise => diff --git a/projects/kit/auth-firebase/src/public-api.ts b/projects/kit/auth-firebase/src/public-api.ts index a079ef7..1e22243 100644 --- a/projects/kit/auth-firebase/src/public-api.ts +++ b/projects/kit/auth-firebase/src/public-api.ts @@ -34,6 +34,7 @@ export { } from './kit-firebase-auth'; export type { KitAuthHooks, + KitSignOutOptions, KitAuthStatus, KitResolveAuthStatusOptions, KitReauthWithRetryOptions,