mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f7f83621 | ||
|
|
086d398058 | ||
|
|
373a1a60d8 | ||
|
|
f71f219187 |
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.37",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.37",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -25,6 +25,7 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -5418,6 +5419,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
|
||||
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.37",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -31,6 +31,7 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
|
||||
// Webmail-mediated login. Instead of doing OAuth directly against the JMAP
|
||||
// server (which would need a registered client_id and a mobile redirect URI
|
||||
// allow-listed on the IdP), the app opens the webmail's /mobile-handoff page,
|
||||
// the user signs in there with their normal credentials, and the webmail
|
||||
// redirects back to the app's custom scheme with the verified credentials in
|
||||
// the URL fragment. Fragments aren't sent to the server, so the password
|
||||
// never appears in HTTP access logs along the way.
|
||||
|
||||
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
||||
|
||||
export interface HandoffCredentials {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class HandoffError extends Error {}
|
||||
export class HandoffCancelledError extends HandoffError {
|
||||
constructor() {
|
||||
super('Sign-in cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
let s = '';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildHandoffUrl(webmailUrl: string, state: string): string {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: HANDOFF_REDIRECT_URI,
|
||||
state,
|
||||
});
|
||||
return `${base}/mobile-handoff?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseFragment(url: string): URLSearchParams {
|
||||
const hashIdx = url.indexOf('#');
|
||||
if (hashIdx === -1) return new URLSearchParams();
|
||||
return new URLSearchParams(url.slice(hashIdx + 1));
|
||||
}
|
||||
|
||||
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffCredentials> {
|
||||
const state = randomState();
|
||||
const handoffUrl = buildHandoffUrl(webmailUrl, state);
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') {
|
||||
throw new HandoffCancelledError();
|
||||
}
|
||||
if (result.type !== 'success' || !result.url) {
|
||||
throw new HandoffError(`Sign-in failed: ${result.type}`);
|
||||
}
|
||||
|
||||
const params = parseFragment(result.url);
|
||||
const err = params.get('error');
|
||||
if (err) throw new HandoffError(err);
|
||||
|
||||
// CSRF guard: the state we generated must round-trip through the webmail
|
||||
// unchanged. A mismatch means the redirect didn't come from the flow we
|
||||
// started, and the rest of the fragment shouldn't be trusted.
|
||||
if (params.get('state') !== state) {
|
||||
throw new HandoffError('State mismatch');
|
||||
}
|
||||
|
||||
const serverUrl = params.get('server_url');
|
||||
const username = params.get('username');
|
||||
const password = params.get('password');
|
||||
if (!serverUrl || !username || !password) {
|
||||
throw new HandoffError('Sign-in response missing credentials');
|
||||
}
|
||||
|
||||
return { serverUrl, username, password };
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
@@ -20,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -30,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -44,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebmailHandoff = async () => {
|
||||
if (!canHandoff) return;
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
|
||||
// loginViaWebmail swallows the "user cancelled the browser" case
|
||||
// silently — only fire the navigation hook when authentication
|
||||
// actually completed.
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if (after && !before) {
|
||||
onLogin?.();
|
||||
} else if (isAddMode && after) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
@@ -149,10 +170,17 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* OAuth buttons */}
|
||||
{/* Webmail-mediated login */}
|
||||
<View style={styles.oauthRow}>
|
||||
<Button variant="outline" size="md" disabled>
|
||||
SSO Provider
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
void handleWebmailHandoff();
|
||||
}}
|
||||
disabled={!canHandoff || isLoading}
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEmailStore } from './email-store';
|
||||
import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
@@ -38,6 +39,7 @@ export interface AuthState {
|
||||
client: typeof jmapClient | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
@@ -167,6 +169,27 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let creds;
|
||||
try {
|
||||
creds = await runWebmailHandoff(webmailUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof HandoffCancelledError) {
|
||||
// User closed the browser tab — quiet exit, no error banner.
|
||||
set({ isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
// Hand the verified credentials to the existing password login path so
|
||||
// session establishment, account registration, and feature-store wiring
|
||||
// all behave identically to a manual sign-in.
|
||||
await get().login(creds.serverUrl, creds.username, creds.password, opts);
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
@@ -64,3 +64,11 @@ vi.mock('expo-secure-store', () => ({
|
||||
getItemAsync: vi.fn(async () => null),
|
||||
deleteItemAsync: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// expo-web-browser transitively pulls in expo-modules-core, which evaluates
|
||||
// RN-only globals at module load. The unit tests don't exercise the real
|
||||
// browser handoff flow, so stubbing the surface is enough.
|
||||
vi.mock('expo-web-browser', () => ({
|
||||
openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })),
|
||||
maybeCompleteAuthSession: vi.fn(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user