UNPKG

@oxyhq/services

Version:

OxyHQ Expo/React Native SDK — UI components, screens, and native features

600 lines (580 loc) 17 kB
"use strict"; /** * OxyAuthScreen - Sign in with Oxy * * This screen is used by OTHER apps in the Oxy ecosystem to authenticate users. * It presents two options: * 1. Scan QR code with Oxy Accounts app * 2. Open the Oxy Auth web flow * * Uses WebSocket for real-time authorization updates (with polling fallback). * The Oxy Accounts app is where users manage their cryptographic identity. * This screen should NOT be used within the Accounts app itself. */ import { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, Linking, Platform, ActivityIndicator } from 'react-native'; import io from 'socket.io-client'; import { useThemeColors } from "../styles/index.js"; import { useOxy } from "../context/OxyContext.js"; import QRCode from 'react-native-qrcode-svg'; import OxyLogo from "../components/OxyLogo.js"; import { createDebugLogger } from '@oxyhq/core'; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const debug = createDebugLogger('OxyAuthScreen'); const OXY_ACCOUNTS_WEB_URL = 'https://accounts.oxy.so'; const OXY_AUTH_WEB_URL = 'https://auth.oxy.so'; // Auth session expiration (5 minutes) const AUTH_SESSION_EXPIRY_MS = 5 * 60 * 1000; // Polling interval (fallback if socket fails) const POLLING_INTERVAL_MS = 3000; const resolveAuthWebBaseUrl = (baseURL, authWebUrl) => { if (authWebUrl) { return authWebUrl; } try { const url = new URL(baseURL); if (url.port === '3001') { url.port = '3000'; return url.origin; } if (url.hostname.startsWith('api.')) { url.hostname = `auth.${url.hostname.slice(4)}`; return url.origin; } } catch { // Ignore parsing errors, fall back to default. } return OXY_AUTH_WEB_URL; }; const resolveAuthRedirectUri = async authRedirectUri => { if (authRedirectUri) { return authRedirectUri; } try { const initialUrl = await Linking.getInitialURL(); if (!initialUrl) { return null; } const parsed = new URL(initialUrl); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } catch { return null; } }; const getRedirectParams = url => { try { const parsed = new URL(url); const sessionId = parsed.searchParams.get('session_id') ?? undefined; const error = parsed.searchParams.get('error') ?? undefined; if (!sessionId && !error) { return null; } return { sessionId, error }; } catch { return null; } }; const OxyAuthScreen = ({ navigate, goBack, onAuthenticated, theme }) => { const themeValue = theme === 'light' || theme === 'dark' ? theme : 'light'; const colors = useThemeColors(themeValue); const { oxyServices, signIn, switchSession } = useOxy(); const [authSession, setAuthSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isWaiting, setIsWaiting] = useState(false); const [connectionType, setConnectionType] = useState('socket'); const socketRef = useRef(null); const pollingIntervalRef = useRef(null); const isProcessingRef = useRef(false); const linkingHandledRef = useRef(false); // Handle successful authorization const handleAuthSuccess = useCallback(async sessionId => { if (isProcessingRef.current) return; isProcessingRef.current = true; try { // Switch to the new session (this will get token, user data, and update state) if (switchSession) { const user = await switchSession(sessionId); if (onAuthenticated) { onAuthenticated(user); } } else { // Fallback if switchSession not available (shouldn't happen, but for safety) await oxyServices.getTokenBySession(sessionId); const user = await oxyServices.getUserBySession(sessionId); if (onAuthenticated) { onAuthenticated(user); } } } catch (err) { debug.error('Error completing auth:', err); setError('Authorization successful but failed to complete sign in. Please try again.'); isProcessingRef.current = false; } }, [oxyServices, switchSession, onAuthenticated]); // Connect to socket for real-time updates const connectSocket = useCallback(sessionToken => { const baseURL = oxyServices.getBaseURL(); // Connect to the auth-session namespace (no authentication required) const socket = io(`${baseURL}/auth-session`, { transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: 3, reconnectionDelay: 1000 }); socketRef.current = socket; socket.on('connect', () => { debug.log('Auth socket connected'); // Join the room for this session token socket.emit('join', sessionToken); setConnectionType('socket'); }); socket.on('joined', () => { debug.log('Joined auth session room'); }); socket.on('auth_update', payload => { debug.log('Auth update received:', payload); if (payload.status === 'authorized' && payload.sessionId) { cleanup(); handleAuthSuccess(payload.sessionId); } else if (payload.status === 'cancelled') { cleanup(); setError('Authorization was denied.'); } else if (payload.status === 'expired') { cleanup(); setError('Session expired. Please try again.'); } }); socket.on('connect_error', err => { debug.log('Socket connection error, falling back to polling:', err.message); // Fall back to polling if socket fails socket.disconnect(); startPolling(sessionToken); }); socket.on('disconnect', () => { debug.log('Auth socket disconnected'); }); }, [oxyServices, handleAuthSuccess]); // Start polling for authorization (fallback) const startPolling = useCallback(sessionToken => { setConnectionType('polling'); pollingIntervalRef.current = setInterval(async () => { if (isProcessingRef.current) return; try { const response = await oxyServices.makeRequest('GET', `/auth/session/status/${sessionToken}`, undefined, { cache: false }); if (response.authorized && response.sessionId) { cleanup(); handleAuthSuccess(response.sessionId); } else if (response.status === 'cancelled') { cleanup(); setError('Authorization was denied.'); } else if (response.status === 'expired') { cleanup(); setError('Session expired. Please try again.'); } } catch (err) { // Silent fail for polling - will retry debug.log('Auth polling error:', err); } }, POLLING_INTERVAL_MS); }, [oxyServices, handleAuthSuccess]); // Cleanup socket and polling const cleanup = useCallback(() => { setIsWaiting(false); if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }, []); // Generate a new auth session const generateAuthSession = useCallback(async () => { setIsLoading(true); setError(null); isProcessingRef.current = false; try { // Generate a unique session token for this auth request const sessionToken = generateSessionToken(); const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS; // Register the auth session with the server await oxyServices.makeRequest('POST', '/auth/session/create', { sessionToken, expiresAt, appId: Platform.OS // Identifier for requesting app }, { cache: false }); setAuthSession({ sessionToken, expiresAt }); setIsWaiting(true); // Try socket first, will fall back to polling if needed connectSocket(sessionToken); } catch (err) { setError(err.message || 'Failed to create auth session'); } finally { setIsLoading(false); } }, [oxyServices, connectSocket]); // Generate a random session token const generateSessionToken = () => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; }; // Clean up on unmount useEffect(() => { return () => { cleanup(); }; }, [cleanup]); // Initialize auth session useEffect(() => { generateAuthSession(); }, []); // Check if session expired useEffect(() => { if (authSession && Date.now() > authSession.expiresAt) { cleanup(); setAuthSession(null); setError('Session expired. Please try again.'); } }, [authSession, cleanup]); // Build the QR code data const getQRData = () => { if (!authSession) return ''; // Format: oxyauth://{sessionToken} return `oxyauth://${authSession.sessionToken}`; }; // Open Oxy Auth web flow const handleOpenAuth = useCallback(async () => { if (!authSession) return; const authBaseUrl = resolveAuthWebBaseUrl(oxyServices.getBaseURL(), oxyServices.config?.authWebUrl); const webUrl = new URL('/authorize', authBaseUrl); webUrl.searchParams.set('token', authSession.sessionToken); const redirectUri = await resolveAuthRedirectUri(oxyServices.config?.authRedirectUri); if (redirectUri) { webUrl.searchParams.set('redirect_uri', redirectUri); } try { await Linking.openURL(webUrl.toString()); } catch (err) { setError('Unable to open Oxy Auth. Please try again or use the QR code.'); } }, [authSession, oxyServices]); // Refresh session const handleRefresh = useCallback(() => { cleanup(); generateAuthSession(); }, [generateAuthSession, cleanup]); const handleAuthRedirect = useCallback(url => { const params = getRedirectParams(url); if (!params) { return; } if (params.error) { cleanup(); setError('Authorization was denied.'); return; } if (params.sessionId) { cleanup(); handleAuthSuccess(params.sessionId); } }, [cleanup, handleAuthSuccess]); useEffect(() => { if (Platform.OS === 'web') { return; } const subscription = Linking.addEventListener('url', ({ url }) => { linkingHandledRef.current = true; handleAuthRedirect(url); }); Linking.getInitialURL().then(url => { if (url && !linkingHandledRef.current) { handleAuthRedirect(url); } }).catch(() => { // Ignore linking errors; auth will still resolve via socket/polling. }); return () => { subscription.remove(); }; }, [handleAuthRedirect]); if (isLoading) { return /*#__PURE__*/_jsxs(View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/_jsx(ActivityIndicator, { size: "large", color: colors.primary }), /*#__PURE__*/_jsx(Text, { style: [styles.loadingText, { color: colors.secondaryText }], children: "Preparing sign in..." })] }); } if (error) { return /*#__PURE__*/_jsxs(View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/_jsx(Text, { style: [styles.errorText, { color: colors.error }], children: error }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.button, { backgroundColor: colors.primary }], onPress: handleRefresh, children: /*#__PURE__*/_jsx(Text, { style: styles.buttonText, children: "Try Again" }) })] }); } return /*#__PURE__*/_jsxs(View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/_jsxs(View, { style: styles.header, children: [/*#__PURE__*/_jsx(OxyLogo, { width: 48, height: 48 }), /*#__PURE__*/_jsx(Text, { style: [styles.title, { color: colors.text }], children: "Sign in with Oxy" }), /*#__PURE__*/_jsx(Text, { style: [styles.subtitle, { color: colors.secondaryText }], children: "Use your Oxy identity to sign in securely" })] }), /*#__PURE__*/_jsxs(View, { style: [styles.qrContainer, { backgroundColor: colors.inputBackground, borderColor: colors.border }], children: [/*#__PURE__*/_jsx(View, { style: styles.qrWrapper, children: /*#__PURE__*/_jsx(QRCode, { value: getQRData(), size: 200, backgroundColor: "white", color: "black" }) }), /*#__PURE__*/_jsx(Text, { style: [styles.qrHint, { color: colors.secondaryText }], children: "Scan with Oxy Accounts app" })] }), /*#__PURE__*/_jsxs(View, { style: styles.dividerContainer, children: [/*#__PURE__*/_jsx(View, { style: [styles.divider, { backgroundColor: colors.border }] }), /*#__PURE__*/_jsx(Text, { style: [styles.dividerText, { color: colors.secondaryText }], children: "or" }), /*#__PURE__*/_jsx(View, { style: [styles.divider, { backgroundColor: colors.border }] })] }), /*#__PURE__*/_jsxs(TouchableOpacity, { style: [styles.button, { backgroundColor: colors.primary }], onPress: handleOpenAuth, children: [/*#__PURE__*/_jsx(OxyLogo, { width: 20, height: 20, fillColor: "white", style: styles.buttonIcon }), /*#__PURE__*/_jsx(Text, { style: styles.buttonText, children: "Open Oxy Auth" })] }), isWaiting && /*#__PURE__*/_jsxs(View, { style: styles.statusContainer, children: [/*#__PURE__*/_jsx(ActivityIndicator, { size: "small", color: colors.primary }), /*#__PURE__*/_jsx(Text, { style: [styles.statusText, { color: colors.secondaryText }], children: "Waiting for authorization..." })] }), /*#__PURE__*/_jsxs(View, { style: styles.footer, children: [/*#__PURE__*/_jsxs(Text, { style: [styles.footerText, { color: colors.secondaryText }], children: ["Don't have Oxy Accounts?", ' '] }), /*#__PURE__*/_jsx(TouchableOpacity, { onPress: () => Linking.openURL(OXY_ACCOUNTS_WEB_URL), children: /*#__PURE__*/_jsx(Text, { style: [styles.footerLink, { color: colors.primary }], children: "Get it here" }) })] }), goBack && /*#__PURE__*/_jsx(TouchableOpacity, { style: styles.cancelButton, onPress: goBack, children: /*#__PURE__*/_jsx(Text, { style: [styles.cancelText, { color: colors.secondaryText }], children: "Cancel" }) })] }); }; const styles = StyleSheet.create({ container: { flex: 1, padding: 24, alignItems: 'center', justifyContent: 'center' }, header: { alignItems: 'center', marginBottom: 32 }, title: { fontSize: 24, fontWeight: 'bold', marginTop: 16 }, subtitle: { fontSize: 14, marginTop: 8, textAlign: 'center' }, qrContainer: { padding: 24, borderRadius: 16, borderWidth: 1, alignItems: 'center' }, qrWrapper: { padding: 16, backgroundColor: 'white', borderRadius: 12 }, qrHint: { marginTop: 16, fontSize: 12 }, dividerContainer: { flexDirection: 'row', alignItems: 'center', marginVertical: 24, width: '100%' }, divider: { flex: 1, height: 1 }, dividerText: { marginHorizontal: 16, fontSize: 14 }, button: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, paddingHorizontal: 24, borderRadius: 12, width: '100%' }, buttonIcon: { marginRight: 10 }, buttonText: { color: 'white', fontSize: 16, fontWeight: '600' }, statusContainer: { flexDirection: 'row', alignItems: 'center', marginTop: 24 }, statusText: { marginLeft: 8, fontSize: 14 }, footer: { flexDirection: 'row', marginTop: 32 }, footerText: { fontSize: 14 }, footerLink: { fontSize: 14, fontWeight: '600' }, cancelButton: { marginTop: 16, padding: 12 }, cancelText: { fontSize: 14 }, loadingText: { marginTop: 16, fontSize: 14 }, errorText: { fontSize: 14, textAlign: 'center', marginBottom: 16 } }); export default OxyAuthScreen; //# sourceMappingURL=OxyAuthScreen.js.map