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