diff --git a/package-lock.json b/package-lock.json index 6a782f5..78c5606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.1", "license": "MIT", "dependencies": { "@meteorrn/minimongo": "1.0.1", diff --git a/package.json b/package.json index 891e4b6..82e5e74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.1", "description": "Meteor Client for React Native", "type": "module", "main": "dist/src/index.js", diff --git a/src/Data.ts b/src/Data.ts index f819c50..06e4c3a 100644 --- a/src/Data.ts +++ b/src/Data.ts @@ -7,9 +7,9 @@ import { import type DDP from '../lib/ddp'; export type KeyStorage = { - getItem(key: string): Promise | string | null; - setItem(key: string, value: string): Promise | void; - removeItem(key: string): Promise | void; + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; }; export type LoggerPayload = object | string; diff --git a/src/index.ts b/src/index.ts index dce70c2..b27e06d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,5 @@ const { useTracker, withTracker, Mongo, ReactiveDict } = MeteorAugmented; export { useTracker, Accounts, withTracker, Mongo, ReactiveDict, Tracker }; export { Vent } from './vent'; +export type { LoginFailurePayload } from './user/User'; export default MeteorAugmented; diff --git a/src/user/Accounts.ts b/src/user/Accounts.ts index 1591c21..3b98608 100644 --- a/src/user/Accounts.ts +++ b/src/user/Accounts.ts @@ -1,6 +1,6 @@ import Data from '../Data'; import call from '../Call'; -import User from './User'; +import User, { type LoginFailurePayload } from './User'; import { hashPassword } from '../../lib/utils'; import Meteor from '../Meteor'; @@ -31,7 +31,7 @@ class AccountsPassword { options.password = hashPassword(options.password) as any; User._startLoggingIn(); - call('createUser', options, (err: any, result: any) => { + call('createUser', options, async (err: any, result: any) => { if (Meteor.isVerbose) { let errText: string; if (err instanceof Error) { @@ -63,7 +63,7 @@ class AccountsPassword { } User._endLoggingIn(); - User._handleLoginCallback(err, result); + await User._handleLoginCallback(err, result); callback(err); }); }; @@ -180,7 +180,7 @@ class AccountsPassword { /** * Register a callback to be called after a login attempt fails. */ - onLoginFailure = (cb: (...args: any[]) => void) => { + onLoginFailure = (cb: (payload: LoginFailurePayload) => void) => { Data.on('onLoginFailure', cb); }; diff --git a/src/user/User.ts b/src/user/User.ts index 063d79e..601b2f7 100644 --- a/src/user/User.ts +++ b/src/user/User.ts @@ -7,13 +7,132 @@ import type { Collection } from '../Collection'; type UserDoc = { _id: string } & Record & T; +export type LoginFailurePayload = { + error: number | string | undefined; + reason: string | undefined; + userId: string | undefined; + token: string | undefined; + details: Record | string | undefined; + message: string; + stack: string | undefined; + isLogoutTriggered: boolean; +}; + const TOKEN_KEY = 'Meteor.loginToken'; const TOKEN_EXPIRATION_KEY = 'Meteor.loginTokenExpires'; const USER_ID_KEY = 'Meteor.userId'; +const RESUME_REJECTION_ERRORS = [ + 403, + 'token-expired', + 'not-authorized', + 'incorrect-auth-token', +] as const; const Users = new (Mongo as any).Collection('users') as Collection< UserDoc >; +const DEFAULT_LOGIN_FAILURE_MESSAGE = 'unknown-login-failure'; + +const normalizeLoginFailure = ( + err: unknown, + isLogoutTriggered: boolean, + userId?: string | null, + token?: string | null +): LoginFailurePayload => { + const basePayload: LoginFailurePayload = { + error: undefined, + reason: undefined, + userId: typeof userId === 'string' ? userId : undefined, + token: typeof token === 'string' ? token : undefined, + details: undefined, + message: DEFAULT_LOGIN_FAILURE_MESSAGE, + stack: undefined, + isLogoutTriggered, + }; + + if (err instanceof Error) { + return { + ...basePayload, + message: err.message || basePayload.message, + stack: err.stack, + }; + } + + if (err && typeof err === 'object') { + const error = (err as { error?: unknown }).error; + const reason = (err as { reason?: unknown }).reason; + const details = (err as { details?: unknown }).details; + const message = (err as { message?: unknown }).message; + const stack = (err as { stack?: unknown }).stack; + + return { + ...basePayload, + error: + typeof error === 'number' || typeof error === 'string' + ? error + : undefined, + reason: typeof reason === 'string' ? reason : undefined, + details: + typeof details === 'string' || (details && typeof details === 'object') + ? (details as Record | string) + : undefined, + message: typeof message === 'string' ? message : basePayload.message, + stack: typeof stack === 'string' ? stack : undefined, + }; + } + + if (typeof err === 'string') { + return { + ...basePayload, + message: err, + }; + } + + if (err !== undefined && err !== null) { + return { + ...basePayload, + message: String(err), + }; + } + + return basePayload; +}; + +const isResumeRejectionError = (value: unknown): boolean => { + if (typeof value !== 'string' && typeof value !== 'number') { + return false; + } + + return RESUME_REJECTION_ERRORS.includes( + value as typeof RESUME_REJECTION_ERRORS[number] + ); +}; + +const formatKeyStorageError = (error: unknown): string => { + if (error instanceof Error) { + return error.stack || error.message || String(error); + } + if (typeof error === 'string') return error; + try { + return JSON.stringify(error); + } catch (_stringifyError) { + return String(error); + } +}; + +const logKeyStorageError = ( + operation: 'setItem' | 'removeItem', + key: string, + error: unknown +): void => { + Meteor.logger({ + event: 'key_storage_error', + key, + error: formatKeyStorageError(error), + operation, + }); +}; + /** * @namespace User * @type {object} @@ -83,13 +202,13 @@ const User = { }, logout(callback?: (err?: any) => void): void { - const finish = (err?: any) => { + const finish = async (err?: any) => { if (err) { User._endLoggingOut(); if (typeof callback === 'function') callback(err); return; } - User.handleLogout(); + await User.handleLogout(); if (typeof callback === 'function') callback(); }; @@ -105,10 +224,20 @@ const User = { } }, - handleLogout(): void { - Data._options.KeyStorage.removeItem(TOKEN_KEY); - Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY); - Data._options.KeyStorage.removeItem(USER_ID_KEY); + async handleLogout(): Promise { + await Promise.all([ + Data._options.KeyStorage.removeItem(TOKEN_KEY).catch((error) => { + logKeyStorageError('removeItem', TOKEN_KEY, error); + }), + Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY).catch( + (error) => { + logKeyStorageError('removeItem', TOKEN_EXPIRATION_KEY, error); + } + ), + Data._options.KeyStorage.removeItem(USER_ID_KEY).catch((error) => { + logKeyStorageError('removeItem', USER_ID_KEY, error); + }), + ]); (Data as any)._tokenIdSaved = null; Meteor._reactiveDict.set('isLoggedIn', false); this._reactiveDict.set('_userIdSaved', null); @@ -139,8 +268,8 @@ const User = { user: sel, password: hashPassword(password), }, - (err: any, result: any) => { - User._handleLoginCallback(err, result); + async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); } ); @@ -167,18 +296,18 @@ const User = { password: hashPassword(password), code, }, - (err: any, result: any) => { - User._handleLoginCallback(err, result); + async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); } ); }, logoutOtherClients(callback: (err?: any) => void = () => {}): void { - Meteor.call('getNewToken', (err: any, res: any) => { + Meteor.call('getNewToken', async (err: any, res: any) => { if (err) return callback(err); - User._handleLoginCallback(err, res); + await User._handleLoginCallback(err, res); Meteor.call('removeOtherTokens', (err2: any) => { callback(err2); @@ -188,8 +317,8 @@ const User = { _login(user: any, callback?: (err?: any) => void): void { User._startLoggingIn(); - Meteor.call('login', user, (err: any, result: any) => { - User._handleLoginCallback(err, result); + Meteor.call('login', user, async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); }); }, @@ -214,7 +343,7 @@ const User = { Data.notify('loggingOut'); }, - _handleLoginCallback(err: any, result: any): void { + async _handleLoginCallback(err: any, result: any): Promise { if (!err) { if (Meteor.isVerbose) { Meteor.logger( @@ -224,20 +353,40 @@ const User = { const normalizedExpiration = User._normalizeTokenExpiration(result?.tokenExpires) ?? null; - Data._options.KeyStorage.setItem(TOKEN_KEY, result.token); - if (result?.id !== null) { - Data._options.KeyStorage.setItem(USER_ID_KEY, String(result.id)); - } else { - Data._options.KeyStorage.removeItem(USER_ID_KEY); - } - if (normalizedExpiration) { - Data._options.KeyStorage.setItem( - TOKEN_EXPIRATION_KEY, - normalizedExpiration - ); - } else { - Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY); - } + const userIdStoragePromise = + result?.id !== null + ? Data._options.KeyStorage.setItem( + USER_ID_KEY, + String(result.id) + ).catch((error) => { + logKeyStorageError('setItem', USER_ID_KEY, error); + }) + : Data._options.KeyStorage.removeItem(USER_ID_KEY).catch((error) => { + logKeyStorageError('removeItem', USER_ID_KEY, error); + }); + + const expirationStoragePromise = normalizedExpiration + ? Data._options.KeyStorage.setItem( + TOKEN_EXPIRATION_KEY, + normalizedExpiration + ).catch((error) => { + logKeyStorageError('setItem', TOKEN_EXPIRATION_KEY, error); + }) + : Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY).catch( + (error) => { + logKeyStorageError('removeItem', TOKEN_EXPIRATION_KEY, error); + } + ); + + await Promise.all([ + Data._options.KeyStorage.setItem(TOKEN_KEY, result.token).catch( + (error) => { + logKeyStorageError('setItem', TOKEN_KEY, error); + } + ), + userIdStoragePromise, + expirationStoragePromise, + ]); (Data as any)._tokenIdSaved = result.token; User._tokenExpirationSaved = normalizedExpiration; this._reactiveDict.set('_loginTokenExpires', normalizedExpiration); @@ -269,7 +418,10 @@ const User = { } User._endLoggingIn(); // we delegate the error to enable better logging - Data.notify('onLoginFailure', err); + Data.notify( + 'onLoginFailure', + normalizeLoginFailure(err, false, User._userIdSaved) + ); } Data.notify('change'); }, @@ -319,7 +471,7 @@ const User = { this._isCallingLogin = true; User._startLoggingIn(); - const respond = (err: any, result: any) => { + const respond = async (err: any, result: any) => { if (Meteor.isVerbose) { Meteor.logger( `User._loginWithToken::: respond err=${safeStringify( @@ -329,6 +481,7 @@ const User = { } this._isCallingLogin = false; let loginError = err; + const userIdSnapshot = User._userIdSaved; const missingToken = !result || typeof (result as any).token !== 'string' || @@ -349,10 +502,7 @@ const User = { } const isRateLimited = loginError?.error == 'too-many-requests'; - const isResumeRejection = - loginError?.error === 403 || - loginError?.error === 'token-expired' || - loginError?.error === 'not-authorized'; + const isResumeRejection = isResumeRejectionError(loginError?.error); if (Meteor.isVerbose && isResumeRejection) { Meteor.logger( @@ -387,6 +537,12 @@ const User = { } if (isRateLimited) { + const failurePayload = normalizeLoginFailure( + loginError, + false, + userIdSnapshot, + token + ); Meteor.isVerbose && Meteor.logger( `User._handleLoginCallback::: too many requests retrying: ${safeStringify( @@ -403,21 +559,33 @@ const User = { if (User._userIdSaved) return; this._loadInitialUser(); }, (time || 0) + 100); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); Data.notify('change'); } else if (isResumeRejection) { + const failurePayload = normalizeLoginFailure( + loginError, + true, + userIdSnapshot, + token + ); this._isTokenLogin = false; Meteor._reactiveDict.set('isLoggedIn', false); - User.handleLogout(); + await User.handleLogout(); User._endLoggingIn(); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); Data.notify('change'); } else if (loginError) { + const failurePayload = normalizeLoginFailure( + loginError, + false, + userIdSnapshot, + token + ); // Treat other errors (e.g. transient connection issues) as retryable this._isTokenLogin = true; Meteor._reactiveDict.set('isLoggedIn', false); User._endLoggingIn(); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); const retryToken = (Data as any)._tokenIdSaved || token; const delay = this._timeout; @@ -428,7 +596,7 @@ const User = { }, delay); Data.notify('change'); } else { - User._handleLoginCallback(loginError, result); + await User._handleLoginCallback(loginError, result); } callback?.(loginError, result); resolve();