diff --git a/CLAUDE.md b/CLAUDE.md index 6244392a..c6f757a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,7 +467,6 @@ These must match Auth0 dashboard configuration. ## Important Notes 1. **New Architecture Enabled**: React 19 with New Architecture (`newArchEnabled: true` in `app.json`) -2. **Biometrics in Expo Go**: Disabled during Expo Go development, enabled in production builds 3. **Zscaler Users**: Ensure Zscaler policies are up-to-date for corporate environments 4. **Node Version**: Use Node.js LTS (20.19.4+) 5. **Android Emulator**: Must be running before `npm run android` diff --git a/README.md b/README.md index b3b904b1..2a6e379d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ A comprehensive React Native mobile application built with Expo, featuring Auth0 **iOS 17 & iOS 18**: Fully supported **Android 14 & Android 15**: Fully supported -> **Note**: Some features (like biometrics) are disabled in Expo Go during development but work properly in production builds. - ## Key Features - **Auth0 Authentication**: OAuth2/OIDC with OTP verification @@ -54,14 +52,9 @@ A comprehensive React Native mobile application built with Expo, featuring Auth0 3. **Start Development** ```bash - npx expo start --clear + npx expo run:ios + npx expo run:android ``` - - Then: - - **iOS**: Press `i` or scan QR code with Camera app - - **Android**: Press `a` or scan QR code with Expo Go app - - Press `r` to reload the app when needed - - Click `Ctrl+C` to shut down the server ### Android Emulator Setup (if needed) @@ -121,10 +114,6 @@ Please ask team members to share specifics **Callback URLs:** ``` -# Development (Expo Go) -exp://[YOUR-IP]:8081/--/auth0 -exp://localhost:8081/--/auth0 - # Production softwareone.playground-platform-navigation://login-dev.pyracloud.com/ios/com.softwareone.marketplaceMobile/callback softwareone.playground-platform-navigation://login-dev.pyracloud.com/android/com.softwareone.marketplaceMobile/callback @@ -210,12 +199,6 @@ npx expo run:ios # Android npx expo run:android - -# Expo Go (Quick Development) -npx expo start - -# Clear Cache -npx expo start --clear ``` ## Local Build - iOS diff --git a/app/App.tsx b/app/App.tsx index 39cad632..e275feb7 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,8 +1,25 @@ +import React from 'react'; import { StatusBar } from 'expo-status-bar'; import { StyleSheet, Text, View } from 'react-native'; +import { AuthProvider } from '@/context/AuthContext'; import { isFeatureEnabled } from '@featureFlags'; import { Colors } from './src/constants/colors'; +const App = () => { + const featureTestEnabled = isFeatureEnabled('FEATURE_TEST'); + + return ( + + + + {featureTestEnabled ? 'Test Feature Enabled' : 'Test Feature Disabled'} + + + + + ); +}; + const styles = StyleSheet.create({ container: { flex: 1, @@ -12,17 +29,4 @@ const styles = StyleSheet.create({ }, }); -const App = () => { - const featureTestEnabled = isFeatureEnabled('FEATURE_TEST'); - - return ( - - - {featureTestEnabled ? 'Test Feature Enabled' : 'Test Feature Disabled'} - - - - ); -} - export default App; diff --git a/app/index.ts b/app/index.ts index 1d6e981e..ce8f2073 100644 --- a/app/index.ts +++ b/app/index.ts @@ -2,7 +2,4 @@ import { registerRootComponent } from 'expo'; import App from './App'; -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately registerRootComponent(App); diff --git a/app/jest.config.js b/app/jest.config.js index 45e1f7f0..2057689c 100644 --- a/app/jest.config.js +++ b/app/jest.config.js @@ -1,9 +1,17 @@ -export default { +module.exports = { preset: 'ts-jest', testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], - testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'], - coverageReporters: ['text', 'lcov', 'json-summary'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js'], + testMatch: [ + '**/__tests__/**/*.test.(ts|tsx|js)', + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + transformIgnorePatterns: [ + 'node_modules/(?!(expo-secure-store|@react-native-async-storage|react-native-auth0)/)', + ], collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', diff --git a/app/package-lock.json b/app/package-lock.json index 39374ae3..42205c11 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,11 +8,16 @@ "name": "mpt-mobile-platform", "version": "4.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "@types/jwt-decode": "^2.2.1", "expo": "~54.0.13", "expo-dev-client": "~6.0.15", + "expo-secure-store": "^15.0.7", "expo-status-bar": "~3.0.8", + "jwt-decode": "^4.0.0", "react": "19.1.0", "react-native": "0.81.4", + "react-native-auth0": "^5.1.0", "react-native-dotenv": "^3.4.11" }, "devDependencies": { @@ -46,6 +51,17 @@ } } }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.7.0.tgz", + "integrity": "sha512-o29ZDbUUCJcEXeBP5LPuacbP28BkNroHuq3jfKbNjFiiWjmrCe95jwPAYb6+9PGyEbs8Wva4fGkVSNZ2HZFEGA==", + "license": "MIT", + "dependencies": { + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -74,6 +90,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1440,6 +1457,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3033,6 +3051,18 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.4", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.4.tgz", @@ -3386,6 +3416,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jwt-decode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz", + "integrity": "sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", @@ -3401,6 +3437,7 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3462,6 +3499,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4011,6 +4049,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4584,6 +4623,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4693,6 +4738,16 @@ "node": ">=8" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.27.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", @@ -4712,6 +4767,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -4810,7 +4866,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4824,7 +4879,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5443,11 +5497,19 @@ "url": "https://dotenvx.com" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5601,11 +5663,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5615,7 +5682,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5653,7 +5719,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5742,6 +5807,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6176,6 +6242,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.19.tgz", "integrity": "sha512-ZbCwBfrYnW7p5P9KGP/Dj9B79EpqP1au8qVDtwqqv6lOVHAYE3SWiooiZK9P8Nx3iViAVL11SF+HLOTPX+kaqA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.13", @@ -6342,6 +6409,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.7.tgz", + "integrity": "sha512-9q7+G1Zxr5P6J5NRIlm86KulvmYwc6UnQlYPjQLDu1drDnerz6AT6l884dPu29HgtDTn4rR0heYeeGFhMKM7/Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.2.tgz", @@ -6677,6 +6753,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7149,7 +7226,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7183,7 +7259,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7335,7 +7410,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7444,7 +7518,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8003,6 +8076,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8308,6 +8390,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9057,6 +9140,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9403,6 +9495,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -9588,7 +9686,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9600,6 +9697,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -10499,7 +10608,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11332,6 +11440,21 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -11425,6 +11548,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -11477,6 +11601,30 @@ } } }, + "node_modules/react-native-auth0": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-native-auth0/-/react-native-auth0-5.1.0.tgz", + "integrity": "sha512-6VafQ8a1faqDZnx7OVL516F5geuT809B18GIIBsA9b4ReS0HR2lHJ5EuLnMVMiOeehCh89aUDdBTC+dWf7DK6w==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "@auth0/auth0-spa-js": "2.7.0", + "base-64": "^1.0.0", + "jwt-decode": "^4.0.0", + "url": "^0.11.4" + }, + "peerDependencies": { + "react": ">=19.0.0", + "react-native": ">=0.78.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/react-native-dotenv": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz", @@ -12558,7 +12706,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12578,7 +12725,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12595,7 +12741,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12614,7 +12759,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -13512,6 +13656,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13704,6 +13849,25 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -14139,6 +14303,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/app/package.json b/app/package.json index e6dc9f2e..caea6553 100644 --- a/app/package.json +++ b/app/package.json @@ -13,11 +13,16 @@ "test": "jest" }, "dependencies": { + "@react-native-async-storage/async-storage": "^2.2.0", + "@types/jwt-decode": "^2.2.1", "expo": "~54.0.13", "expo-dev-client": "~6.0.15", + "expo-secure-store": "^15.0.7", "expo-status-bar": "~3.0.8", + "jwt-decode": "^4.0.0", "react": "19.1.0", "react-native": "0.81.4", + "react-native-auth0": "^5.1.0", "react-native-dotenv": "^3.4.11" }, "devDependencies": { diff --git a/app/src/__tests__/authIntegration.test.ts b/app/src/__tests__/authIntegration.test.ts new file mode 100644 index 00000000..ccfefb57 --- /dev/null +++ b/app/src/__tests__/authIntegration.test.ts @@ -0,0 +1,74 @@ +import authService from '../services/authService'; +import credentialStorageService from '../services/credentialStorageService'; +import { AuthTokens } from '../services/authService'; +import { jwtDecode } from 'jwt-decode'; + +jest.mock('../services/authService'); +jest.mock('../services/credentialStorageService'); +jest.mock('jwt-decode'); + +const mockAuthService = authService as jest.Mocked; +const mockCredentialStorageService = credentialStorageService as jest.Mocked; +const mockJwtDecode = jwtDecode as jest.MockedFunction; + +describe('AuthContext Integration Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockAuthService.isTokenExpired.mockReturnValue(false); + mockCredentialStorageService.loadStoredCredentials.mockResolvedValue({ + refreshToken: null, + metadata: null, + user: null, + }); + }); + + describe('Auth0 Response Parsing', () => { + it('should correctly parse Auth0 login response with all fields', async () => { + const mockAuth0LoginResponse = { + accessToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...', + refreshToken: 'refresh_token_value', + tokenType: 'Bearer', + expiresIn: 3600, + }; + + const expectedParsedTokens: AuthTokens = { + accessToken: mockAuth0LoginResponse.accessToken, + refreshToken: mockAuth0LoginResponse.refreshToken, + tokenType: mockAuth0LoginResponse.tokenType, + expiresAt: 1699127056, // Mocked calculation from JWT + }; + + // Mock JWT decoding + mockJwtDecode.mockReturnValue({ + exp: 1699127056, + iat: 1699123456, + sub: 'auth0|123456789', + }); + + mockAuthService.verifyPasswordlessOtp.mockResolvedValue(expectedParsedTokens); + + const result = await authService.verifyPasswordlessOtp('test@example.com', '123456'); + + expect(result).toEqual(expectedParsedTokens); + expect(result.accessToken).toBe(mockAuth0LoginResponse.accessToken); + expect(result.refreshToken).toBe(mockAuth0LoginResponse.refreshToken); + expect(result.tokenType).toBe(mockAuth0LoginResponse.tokenType); + expect(result.expiresAt).toBeDefined(); + }); + + it('should handle Auth0 error responses correctly', async () => { + const mockAuth0ErrorResponse = { + error: 'invalid_grant', + error_description: 'Invalid verification code', + }; + + mockAuthService.verifyPasswordlessOtp.mockRejectedValue( + new Error(`Auth0 Error: ${mockAuth0ErrorResponse.error} - ${mockAuth0ErrorResponse.error_description}`) + ); + + await expect(authService.verifyPasswordlessOtp('test@example.com', 'wrong_code')) + .rejects.toThrow('Auth0 Error: invalid_grant - Invalid verification code'); + }); + }); +}); \ No newline at end of file diff --git a/app/src/__tests__/credentialStorageService.test.ts b/app/src/__tests__/credentialStorageService.test.ts new file mode 100644 index 00000000..a04b2252 --- /dev/null +++ b/app/src/__tests__/credentialStorageService.test.ts @@ -0,0 +1,122 @@ +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { credentialStorageService } from '../services/credentialStorageService'; +import { AuthTokens } from '../services/authService'; + +jest.mock('expo-secure-store'); +jest.mock('@react-native-async-storage/async-storage'); + +const mockSecureStore = SecureStore as jest.Mocked; +const mockAsyncStorage = AsyncStorage as jest.Mocked; + +describe('CredentialStorageService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Core Security Requirements', () => { + const mockTokens: AuthTokens = { + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: 1699123456, + tokenType: 'Bearer', + }; + + it('should NEVER store access tokens in persistent storage', async () => { + mockSecureStore.setItemAsync.mockResolvedValue(); + mockAsyncStorage.setItem.mockResolvedValue(); + + await credentialStorageService.storeTokens(mockTokens); + + // Verify access token is never stored anywhere + const asyncStorageCall = mockAsyncStorage.setItem.mock.calls[0]; + const storedData = JSON.parse(asyncStorageCall[1]); + expect(storedData).not.toHaveProperty('accessToken'); + expect(storedData).not.toHaveProperty('refreshToken'); + }); + + it('should store refresh token in SecureStore and metadata in AsyncStorage', async () => { + mockSecureStore.setItemAsync.mockResolvedValue(); + mockAsyncStorage.setItem.mockResolvedValue(); + + await credentialStorageService.storeTokens(mockTokens); + + // Verify refresh token stored securely + expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith( + 'auth_refresh_token', + 'mock-refresh-token' + ); + + // Verify only metadata stored in AsyncStorage + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'auth_tokens', + JSON.stringify({ + expiresAt: 1699123456, + tokenType: 'Bearer', + }) + ); + }); + + it('should handle missing refresh token gracefully', async () => { + const tokensWithoutRefresh: AuthTokens = { + accessToken: 'mock-access-token', + expiresAt: 1699123456, + tokenType: 'Bearer', + }; + + mockAsyncStorage.setItem.mockResolvedValue(); + + await credentialStorageService.storeTokens(tokensWithoutRefresh); + + expect(mockSecureStore.setItemAsync).not.toHaveBeenCalled(); + expect(mockAsyncStorage.setItem).toHaveBeenCalled(); + }); + }); + + describe('Basic Operations', () => { + it('should clear all stored credentials', async () => { + mockAsyncStorage.removeItem.mockResolvedValue(); + mockSecureStore.deleteItemAsync.mockResolvedValue(); + + await credentialStorageService.clearAllCredentials(); + + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('auth_tokens'); + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith('auth_user'); + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith('auth_refresh_token'); + }); + + it('should check stored credentials existence', async () => { + mockAsyncStorage.getItem + .mockResolvedValueOnce('{"expiresAt":123}') + .mockResolvedValueOnce('{"sub":"auth0|123"}'); + + const result = await credentialStorageService.hasStoredCredentials(); + + expect(result).toBe(true); + }); + }); + + describe('Error Handling', () => { + it('should handle storage errors gracefully', async () => { + const mockTokens: AuthTokens = { + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: 1699123456, + tokenType: 'Bearer', + }; + + mockSecureStore.setItemAsync.mockRejectedValue(new Error('SecureStore error')); + mockAsyncStorage.setItem.mockRejectedValue(new Error('AsyncStorage error')); + + await expect(credentialStorageService.storeTokens(mockTokens)).resolves.toBeUndefined(); + }); + + it('should handle retrieval errors gracefully', async () => { + mockAsyncStorage.getItem.mockRejectedValue(new Error('Storage error')); + + const result = await credentialStorageService.hasStoredCredentials(); + + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/app/src/__tests__/setup.ts b/app/src/__tests__/setup.ts new file mode 100644 index 00000000..22735e7b --- /dev/null +++ b/app/src/__tests__/setup.ts @@ -0,0 +1,52 @@ +jest.mock('expo-secure-store', () => ({ + setItemAsync: jest.fn(), + getItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), +})); + +jest.mock('react-native-auth0', () => { + return jest.fn().mockImplementation(() => ({ + auth: { + passwordlessWithEmail: jest.fn(), + loginWithEmail: jest.fn(), + refreshToken: jest.fn(), + userInfo: jest.fn(), + revoke: jest.fn(), + }, + })); +}); + +jest.mock('jwt-decode', () => ({ + jwtDecode: jest.fn(), +})); + +jest.mock('@/config/environment', () => ({ + default: { + AUTH0_DOMAIN: 'test-domain.auth0.com', + AUTH0_CLIENT_ID: 'test-client-id', + AUTH0_AUDIENCE: 'test-audience', + AUTH0_SCOPE: 'openid profile email', + }, +})); + +const originalConsoleError = console.error; +const originalConsoleLog = console.log; +const originalConsoleWarn = console.warn; + +beforeAll(() => { + console.error = jest.fn(); + console.log = jest.fn(); + console.warn = jest.fn(); +}); + +afterAll(() => { + console.error = originalConsoleError; + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; +}); \ No newline at end of file diff --git a/app/src/config/environment.ts b/app/src/config/environment.ts new file mode 100644 index 00000000..028748be --- /dev/null +++ b/app/src/config/environment.ts @@ -0,0 +1,10 @@ +export const config = { + AUTH0_DOMAIN: process.env.AUTH0_DOMAIN || '', + AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID || '', + AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE || '', + AUTH0_SCOPE: process.env.AUTH0_SCOPE || 'openid profile email offline_access', + AUTH0_OTP_DIGITS: parseInt(process.env.AUTH0_OTP_DIGITS || '6', 10), + AUTH0_SCHEME: process.env.AUTH0_SCHEME || '', +} as const; + +export default config; \ No newline at end of file diff --git a/app/src/context/AuthContext.tsx b/app/src/context/AuthContext.tsx new file mode 100644 index 00000000..0c727efa --- /dev/null +++ b/app/src/context/AuthContext.tsx @@ -0,0 +1,266 @@ +import React, { createContext, useContext, useReducer, useEffect, useCallback, PropsWithChildren } from 'react'; +import authService, { AuthTokens, User } from '@/services/authService'; +import credentialStorageService from '@/services/credentialStorageService'; + +export type AuthState = 'loading' | 'unauthenticated' | 'authenticated'; + +const AUTH_ACTIONS = { + SET_LOADING: 'SET_LOADING', + SET_AUTHENTICATED: 'SET_AUTHENTICATED', + SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', + UPDATE_TOKENS: 'UPDATE_TOKENS', +} as const; + +interface AuthContextType { + status: AuthState; + user: User | null; + tokens: AuthTokens | null; + login: (email: string, otp: string) => Promise; + logout: () => Promise; + sendPasswordlessEmail: (email: string) => Promise; + resendPasswordlessEmail: (email: string) => Promise; + refreshAuth: () => Promise; + getAccessToken: () => Promise; +} + +type AuthAction = + | { type: typeof AUTH_ACTIONS.SET_LOADING } + | { type: typeof AUTH_ACTIONS.SET_AUTHENTICATED; payload: { user: User; tokens: AuthTokens } } + | { type: typeof AUTH_ACTIONS.SET_UNAUTHENTICATED } + | { type: typeof AUTH_ACTIONS.UPDATE_TOKENS; payload: AuthTokens }; + +interface AuthReducerState { + status: AuthState; + user: User | null; + tokens: AuthTokens | null; +} + +const authReducer = (state: AuthReducerState, action: AuthAction): AuthReducerState => { + switch (action.type) { + case AUTH_ACTIONS.SET_LOADING: + return { + ...state, + status: 'loading', + }; + case AUTH_ACTIONS.SET_AUTHENTICATED: + return { + status: 'authenticated', + user: action.payload.user, + tokens: action.payload.tokens, + }; + case AUTH_ACTIONS.SET_UNAUTHENTICATED: + return { + status: 'unauthenticated', + user: null, + tokens: null, + }; + case AUTH_ACTIONS.UPDATE_TOKENS: + return { + ...state, + tokens: action.payload, + }; + default: + return state; + } +}; + +const initialState: AuthReducerState = { + status: 'loading', + user: null, + tokens: null, +}; + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: PropsWithChildren) => { + const [authState, dispatch] = useReducer(authReducer, initialState); + const REFRESH_BUFFER_MINUTES = 5; + const REFRESH_BUFFER_MS = REFRESH_BUFFER_MINUTES * 60 * 1000; + + const setUnauthenticated = useCallback(async () => { + await credentialStorageService.clearAllCredentials(); + dispatch({ type: AUTH_ACTIONS.SET_UNAUTHENTICATED }); + }, []); + + const setAuthenticated = useCallback((user: User, tokens: AuthTokens) => { + dispatch({ + type: AUTH_ACTIONS.SET_AUTHENTICATED, + payload: { user, tokens }, + }); + }, []); + + const loadStoredAuth = useCallback(async () => { + try { + const { refreshToken, user } = await credentialStorageService.loadStoredCredentials(); + if (!refreshToken || !user) { + await setUnauthenticated(); + return; + } + + const newTokens = await authService.refreshAccessToken(refreshToken); + await credentialStorageService.storeTokens(newTokens); + setAuthenticated(user, newTokens); + } catch (error) { + console.error('Failed to load stored auth:', error instanceof Error ? error.message : error); + await setUnauthenticated(); + } + }, [setUnauthenticated, setAuthenticated]); + + useEffect(() => { + loadStoredAuth(); + }, [loadStoredAuth]); + + const logout = useCallback(async () => { + try { + if (authState.tokens?.refreshToken) { + await authService.logout(authState.tokens.refreshToken); + } + } catch (error) { + console.error('Logout API error:', error instanceof Error ? error.message : error); + } finally { + await credentialStorageService.clearAllCredentials(); + dispatch({ type: AUTH_ACTIONS.SET_UNAUTHENTICATED }); + } + }, [authState.tokens?.refreshToken]); + + const refreshAuth = useCallback(async (): Promise => { + try { + if (!authState.tokens?.refreshToken) { + throw new Error('No refresh token available'); + } + + const newTokens = await authService.refreshAccessToken(authState.tokens.refreshToken); + await credentialStorageService.storeTokens(newTokens); + + dispatch({ + type: AUTH_ACTIONS.UPDATE_TOKENS, + payload: newTokens, + }); + + return newTokens; + } catch (error) { + console.error('Failed to refresh auth:', error instanceof Error ? error.message : error); + await logout(); + return null; + } + }, [authState.tokens?.refreshToken, logout]); + + const calculateTimeUntilRefresh = useCallback((expiresAt: number): number => { + const tokenExpiryMs = expiresAt * 1000; + const currentTimeMs = Date.now(); + return tokenExpiryMs - currentTimeMs - REFRESH_BUFFER_MS; + }, [REFRESH_BUFFER_MS]); + + useEffect(() => { + console.log('Setting up token refresh effect'); + if (!authState.tokens || authState.status !== 'authenticated') { + return; + } + + if (!authState.tokens.expiresAt) { + return; + } + + const timeUntilRefresh = calculateTimeUntilRefresh(authState.tokens.expiresAt); + if (timeUntilRefresh <= 0) { + console.warn('Token is expiring soon, refreshing immediately'); + refreshAuth(); + return; + } + + const timer = setTimeout(() => { + refreshAuth(); + }, timeUntilRefresh); + + return () => clearTimeout(timer); + }, [authState.tokens, authState.status, refreshAuth, calculateTimeUntilRefresh]); + + const sendPasswordlessEmail = async (email: string) => { + try { + await authService.sendPasswordlessEmail(email); + } catch (error) { + console.error('Send passwordless email error:', error instanceof Error ? error.message : error); + throw error; + } + }; + + const login = async (email: string, otp: string) => { + try { + dispatch({ type: AUTH_ACTIONS.SET_LOADING }); + + const tokens = await authService.verifyPasswordlessOtp(email, otp); + const user = await authService.getUserProfile(tokens.accessToken); + + await Promise.all([ + credentialStorageService.storeTokens(tokens), + credentialStorageService.storeUser(user), + ]); + + dispatch({ + type: AUTH_ACTIONS.SET_AUTHENTICATED, + payload: { + user, + tokens, + }, + }); + } catch (error) { + dispatch({ type: AUTH_ACTIONS.SET_UNAUTHENTICATED }); + throw error; + } + }; + + const resendPasswordlessEmail = async (email: string) => { + try { + await authService.resendPasswordlessEmail(email); + } catch (error) { + console.error('Resend passwordless email error:', error instanceof Error ? error.message : error); + throw error; + } + }; + + const getAccessToken = useCallback(async (): Promise => { + if (authState.status !== 'authenticated' || !authState.tokens) { + return null; + } + + if (!authService.isTokenExpired(authState.tokens.expiresAt)) { + return authState.tokens.accessToken; + } + + try { + const newTokens = await refreshAuth(); + return newTokens?.accessToken ?? authState.tokens?.accessToken ?? null; + } catch (error) { + console.error('Failed to refresh token for API call:', error instanceof Error ? error.message : error); + return null; + } + }, [authState.status, authState.tokens, refreshAuth]); + + const value: AuthContextType = { + status: authState.status, + user: authState.user, + tokens: authState.tokens, + login, + logout, + sendPasswordlessEmail, + resendPasswordlessEmail, + refreshAuth, + getAccessToken, + }; + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export default AuthContext; \ No newline at end of file diff --git a/app/src/services/authService.ts b/app/src/services/authService.ts new file mode 100644 index 00000000..34d2400c --- /dev/null +++ b/app/src/services/authService.ts @@ -0,0 +1,173 @@ +import config from '@/config/environment'; +import Auth0 from 'react-native-auth0'; +import { jwtDecode } from 'jwt-decode'; + +export interface AuthTokens { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + tokenType?: string; +} + +export interface User { + sub: string; + email?: string; + name?: string; + picture?: string; + email_verified?: boolean; + [key: string]: any; +} + +export interface Auth0PasswordlessResponse { + success: boolean; + message?: string; +} + +export interface Auth0TokenResponse { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; +} + +interface JWTPayload { + exp?: number; + iat?: number; + sub?: string; + [key: string]: any; +} + +class AuthenticationService { + private auth0: Auth0; + private domain: string; + private clientId: string; + private audience?: string; + + constructor() { + this.domain = config.AUTH0_DOMAIN; + this.clientId = config.AUTH0_CLIENT_ID; + this.audience = config.AUTH0_AUDIENCE; + + this.auth0 = new Auth0({ + domain: this.domain, + clientId: this.clientId, + }); + } + + private getExpiryFromJWT(accessToken: string): number | undefined { + try { + const decoded = jwtDecode(accessToken); + return decoded.exp; + } catch (error) { + console.error('Failed to decode JWT:', error instanceof Error ? error.message : error); + return undefined; + } + } + + async sendPasswordlessEmail(email: string): Promise { + try { + await this.auth0.auth.passwordlessWithEmail({ + email, + send: 'code', + authParams: { + scope: config.AUTH0_SCOPE, + ...(this.audience && { audience: this.audience }), + }, + }); + + return { success: true }; + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Failed to send authentication email' + ); + } + } + + async verifyPasswordlessOtp(email: string, otp: string): Promise { + try { + const result = await this.auth0.auth.loginWithEmail({ + email, + code: otp, + audience: this.audience, + scope: config.AUTH0_SCOPE, + }); + + const expiresAt = this.getExpiryFromJWT(result.accessToken); + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenType: result.tokenType || 'Bearer', + expiresAt, + }; + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Failed to verify authentication code' + ); + } + } + + async refreshAccessToken(refreshToken: string): Promise { + try { + const result = await this.auth0.auth.refreshToken({ refreshToken }); + const expiresAt = this.getExpiryFromJWT(result.accessToken); + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken || refreshToken, + tokenType: result.tokenType || 'Bearer', + expiresAt, + }; + } catch { + throw new Error('Failed to refresh authentication'); + } + } + + async getUserProfile(accessToken: string): Promise { + try { + const userData = await this.auth0.auth.userInfo({ token: accessToken }); + + return userData; + } catch { + throw new Error('Failed to get user profile'); + } + } + + async logout(refreshToken?: string): Promise { + let success = true; + + try { + if (refreshToken) { + await this.auth0.auth.revoke({ refreshToken }); + } + } catch { + success = false; + } + + try { + if (this.auth0.credentialsManager) { + await this.auth0.credentialsManager.clearCredentials(); + } + } catch { + success = false; + } + + return success; + } + + isTokenExpired(expiresAt?: number): boolean { + if (!expiresAt) return true; + return Date.now() >= expiresAt * 1000; + } + + async resendPasswordlessEmail(email: string): Promise { + return this.sendPasswordlessEmail(email); + } +} + +export const authService = new AuthenticationService(); +export default authService; \ No newline at end of file diff --git a/app/src/services/credentialStorageService.ts b/app/src/services/credentialStorageService.ts new file mode 100644 index 00000000..e3269f3a --- /dev/null +++ b/app/src/services/credentialStorageService.ts @@ -0,0 +1,144 @@ +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AuthTokens, User } from './authService'; + +export interface StorageKeys { + TOKENS: string; + REFRESH_TOKEN: string; + USER: string; +} + +class CredentialStorageService { + private readonly STORAGE_KEYS: StorageKeys = { + TOKENS: 'auth_tokens', + REFRESH_TOKEN: 'auth_refresh_token', + USER: 'auth_user', + }; + + async storeTokens(tokens: AuthTokens): Promise { + try { + if (tokens.refreshToken) { + await SecureStore.setItemAsync(this.STORAGE_KEYS.REFRESH_TOKEN, tokens.refreshToken); + } + + const tokenMetadata = { + expiresAt: tokens.expiresAt, + tokenType: tokens.tokenType, + }; + await AsyncStorage.setItem(this.STORAGE_KEYS.TOKENS, JSON.stringify(tokenMetadata)); + + } catch (error) { + console.error('Failed to store tokens:', error instanceof Error ? error.message : error); + } + } + + async loadTokens(): Promise<{ refreshToken: string | null; metadata: { expiresAt?: number; tokenType?: string } | null }> { + try { + const [storedTokenMetadata, storedRefreshToken] = await Promise.all([ + AsyncStorage.getItem(this.STORAGE_KEYS.TOKENS), + SecureStore.getItemAsync(this.STORAGE_KEYS.REFRESH_TOKEN), + ]); + + let metadata = null; + if (storedTokenMetadata) { + metadata = JSON.parse(storedTokenMetadata); + } + + return { + refreshToken: storedRefreshToken, + metadata, + }; + } catch (error) { + console.error('Failed to load tokens:', error instanceof Error ? error.message : error); + return { refreshToken: null, metadata: null }; + } + } + + async storeUser(user: User): Promise { + try { + await AsyncStorage.setItem(this.STORAGE_KEYS.USER, JSON.stringify(user)); + } catch (error) { + console.error('Failed to store user data:', error instanceof Error ? error.message : error); + } + } + + async loadUser(): Promise { + try { + const storedUser = await AsyncStorage.getItem(this.STORAGE_KEYS.USER); + + if (!storedUser) { + console.log('No stored user data found'); + return null; + } + + const user: User = JSON.parse(storedUser); + return user; + } catch (error) { + console.error('Failed to load user data:', error instanceof Error ? error.message : error); + return null; + } + } + + async loadStoredCredentials(): Promise<{ refreshToken: string | null; user: User | null; metadata: { expiresAt?: number; tokenType?: string } | null }> { + try { + const [tokenData, user] = await Promise.all([ + this.loadTokens(), + this.loadUser(), + ]); + + return { + refreshToken: tokenData.refreshToken, + metadata: tokenData.metadata, + user + }; + } catch (error) { + console.error('Failed to load stored credentials:', error instanceof Error ? error.message : error); + return { refreshToken: null, metadata: null, user: null }; + } + } + + + async storeCredentials(tokens: AuthTokens, user: User): Promise { + try { + await Promise.all([ + this.storeTokens(tokens), + this.storeUser(user), + ]); + } catch (error) { + console.error('Failed to store credentials:', error instanceof Error ? error.message : error); + } + } + + async clearAllCredentials(): Promise { + try { + await Promise.all([ + AsyncStorage.removeItem(this.STORAGE_KEYS.TOKENS), + AsyncStorage.removeItem(this.STORAGE_KEYS.USER), + SecureStore.deleteItemAsync(this.STORAGE_KEYS.REFRESH_TOKEN), + ]); + } catch (error) { + console.error('Failed to clear credentials:', error instanceof Error ? error.message : error); + } + } + + async hasStoredCredentials(): Promise { + try { + const [tokenData, userData] = await Promise.all([ + AsyncStorage.getItem(this.STORAGE_KEYS.TOKENS), + AsyncStorage.getItem(this.STORAGE_KEYS.USER), + ]); + + return !!(tokenData && userData); + } catch (error) { + console.error('Failed to check stored credentials:', error instanceof Error ? error.message : error); + return false; + } + } + + getStorageKeys(): StorageKeys { + return { ...this.STORAGE_KEYS }; + } +} + +export const credentialStorageService = new CredentialStorageService(); +export default credentialStorageService; \ No newline at end of file