Skip to content

Commit 37ee9ad

Browse files
committed
feat: add native passkey authentication for mobile app via expo-better-auth-passkey
Replace browser-only passkeyClient() with expo-better-auth-passkey on native, enabling passkey registration and sign-in on iOS (ASAuthorizationController) and Android (Credential Manager). The core auth client now accepts a passkeyPlugin override so each platform can supply its own implementation. - Add expo-better-auth-passkey dependency and wire expoPasskeyClient() into mobile config - Add "Sign in with passkey" button to mobile AuthDialog (mirrors web) - Support multi-origin PASSKEY_ORIGIN (comma-separated) for web + mobile - Add .well-known static server (nginx) to Docker Compose for apple-app-site-association and assetlinks.json - Register well-known service in manage.sh (app profile, service resolution) - Add iOS associatedDomains (webcredentials) to app.config.ts - Change bundle ID to org.openmapx.app (iOS + Android) - Add apps/mobile/.env.example with all EXPO_PUBLIC_* env vars
1 parent cf7b00b commit 37ee9ad

14 files changed

Lines changed: 164 additions & 10 deletions

File tree

apps/api/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ OSM_CLIENT_SECRET=
109109
# Passkeys / WebAuthn
110110
# RP ID must match the domain users access the app from
111111
PASSKEY_RP_ID=127.0.0.1
112-
PASSKEY_ORIGIN=http://127.0.0.1:3000
112+
# Comma-separated list of allowed origins (web + mobile)
113+
# For Android: add android:apk-key-hash:<BASE64_SHA256> for each signing certificate
114+
# For iOS: the RP ID domain must have a .well-known/apple-app-site-association file
115+
PASSKEY_ORIGIN=http://127.0.0.1:3000,openmapx://
113116

114117
# SMTP (for verification emails, 2FA codes, email change)
115118
# Any SMTP provider works (e.g. Mailgun, Postmark, Amazon SES, self-hosted)

apps/api/src/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ export const auth = betterAuth({
211211
passkey({
212212
rpID: process.env.PASSKEY_RP_ID ?? "localhost",
213213
rpName: "OpenMapX",
214-
origin: process.env.PASSKEY_ORIGIN ?? "http://localhost:3000",
214+
origin: process.env.PASSKEY_ORIGIN
215+
? process.env.PASSKEY_ORIGIN.split(",").map((o) => o.trim())
216+
: ["http://localhost:3000"],
215217
}),
216218
genericOAuth({
217219
config: [

apps/mobile/.env.example

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# URL of the API server (apps/api)
2+
EXPO_PUBLIC_API_URL=http://localhost:3001
3+
4+
# URL of the web app (used for legal pages WebView)
5+
# EXPO_PUBLIC_WEB_URL=https://openmapx.com
6+
7+
# MapTiler Cloud API key — get yours at https://cloud.maptiler.com
8+
# Required for map tiles, transit overlay, and cycling overlay
9+
EXPO_PUBLIC_MAPTILER_KEY=your_maptiler_key_here
10+
11+
# Mapillary access token for street-level imagery
12+
# Get yours at https://www.mapillary.com/dashboard/developers
13+
EXPO_PUBLIC_MAPILLARY_TOKEN=your_mapillary_token_here
14+
15+
# Passkeys / WebAuthn
16+
# Must match PASSKEY_RP_ID on the API server (the domain hosting .well-known files)
17+
# EXPO_PUBLIC_PASSKEY_RP_ID=localhost
18+
19+
# Optional: override CyclOSM tile URL template
20+
# EXPO_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE=https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png

apps/mobile/app.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,21 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
1313
userInterfaceStyle: "automatic",
1414
ios: {
1515
supportsTablet: true,
16-
bundleIdentifier: "com.openmapx.app",
16+
bundleIdentifier: "org.openmapx.app",
17+
associatedDomains: [`webcredentials:${process.env.EXPO_PUBLIC_PASSKEY_RP_ID ?? "localhost"}`],
1718
},
1819
android: {
1920
adaptiveIcon: {
2021
backgroundColor: "#ffffff",
2122
foregroundImage: "./assets/images/icon.png",
2223
},
23-
package: "com.openmapx.app",
24+
package: "org.openmapx.app",
2425
},
2526
plugins: [
2627
"expo-router",
2728
"expo-localization",
2829
"expo-secure-store",
30+
"expo-better-auth-passkey",
2931
"@maplibre/maplibre-react-native",
3032
[
3133
"expo-location",

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@turf/helpers": "^7.3.4",
2828
"@turf/length": "^7.3.4",
2929
"expo": "~55.0.0",
30+
"expo-better-auth-passkey": "^1.4.1",
3031
"expo-clipboard": "~55.0.9",
3132
"expo-constants": "~55.0.9",
3233
"expo-dev-client": "~55.0.18",

apps/mobile/src/components/auth/AuthDialog.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MaterialIcons } from "@expo/vector-icons";
1+
import { MaterialCommunityIcons, MaterialIcons } from "@expo/vector-icons";
22
import { authClient, oauthProviders } from "@openmapx/core";
33
import { useCallback, useState } from "react";
44
import { useTranslation } from "react-i18next";
@@ -184,6 +184,25 @@ export function AuthDialog({ visible, onDismiss }: AuthDialogProps) {
184184
}
185185
}, [email, resetOtp, newPassword, resetForm, t]);
186186

187+
const handlePasskeySignIn = useCallback(async () => {
188+
Keyboard.dismiss();
189+
setLoading(true);
190+
setError(null);
191+
try {
192+
const { error: passkeyError } = await authClient.signIn.passkey();
193+
if (passkeyError) {
194+
if ("code" in passkeyError && passkeyError.code === "AUTH_CANCELLED") return;
195+
setError(String(passkeyError.message ?? t("auth.passkeySignInFailed")));
196+
return;
197+
}
198+
handleClose();
199+
} catch {
200+
setError(t("auth.passkeyAuthFailed"));
201+
} finally {
202+
setLoading(false);
203+
}
204+
}, [handleClose, t]);
205+
187206
const handleOAuthSignIn = useCallback(
188207
async (providerId: string, providerName: string) => {
189208
setLoading(true);
@@ -456,6 +475,22 @@ export function AuthDialog({ visible, onDismiss }: AuthDialogProps) {
456475

457476
<Divider style={styles.divider} />
458477

478+
{/* Passkey sign-in */}
479+
{mode === "sign-in" && (
480+
<Button
481+
mode="outlined"
482+
onPress={handlePasskeySignIn}
483+
disabled={loading}
484+
icon={({ size, color }) => (
485+
<MaterialCommunityIcons name="key-variant" size={size} color={color} />
486+
)}
487+
style={styles.oauthButton}
488+
labelStyle={{ color: theme.colors.onSurface }}
489+
>
490+
{t("auth.signInWithPasskey")}
491+
</Button>
492+
)}
493+
459494
{/* OAuth providers */}
460495
{oauthProviders.map((provider) => (
461496
<Button

apps/mobile/src/lib/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expoClient } from "@better-auth/expo/client";
22
import { authClient, configureApiClient, configureStorage, initAuth } from "@openmapx/core";
3+
import { expoPasskeyClient } from "expo-better-auth-passkey";
34
import * as SecureStore from "expo-secure-store";
45
import { mmkvStorageAdapter } from "./storage";
56

@@ -12,6 +13,7 @@ export function initPlatform(): void {
1213
// the header interceptor can call authClient.getCookie().
1314
initAuth({
1415
baseURL: API_URL,
16+
passkeyPlugin: expoPasskeyClient(),
1517
platformPlugins: [
1618
expoClient({
1719
scheme: "openmapx",

infra/docker/docker-compose.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# Profiles:
88
# (none) Core: postgis, redis
99
# proxy Traefik reverse proxy (TLS termination, path-based routing)
10-
# app API + Web (Fastify + Next.js, built from repo)
10+
# app API + Web + .well-known (Fastify + Next.js + passkey domain files)
1111
# routing Valhalla + OSRM (Valhalla handles planet, OSRM region-only)
1212
# transit MOTIS + OTP (MOTIS handles planet, OTP region-only)
1313
# pelias Elasticsearch + Pelias (self-hosted geocoding)
@@ -88,7 +88,7 @@ services:
8888
CORS_ORIGIN: https://${DOMAIN:-localhost},http://localhost:3000
8989
BETTER_AUTH_URL: https://${DOMAIN:-localhost}
9090
PASSKEY_RP_ID: ${DOMAIN:-localhost}
91-
PASSKEY_ORIGIN: https://${DOMAIN:-localhost}
91+
PASSKEY_ORIGIN: https://${DOMAIN:-localhost},openmapx://
9292
depends_on:
9393
postgis:
9494
condition: service_healthy
@@ -144,6 +144,25 @@ services:
144144
limits:
145145
memory: 512m
146146

147+
well-known:
148+
image: nginx:alpine
149+
profiles: [app]
150+
volumes:
151+
- ./well-known:/usr/share/nginx/html/.well-known:ro
152+
- ./well-known/nginx.conf:/etc/nginx/conf.d/default.conf:ro
153+
labels:
154+
traefik.enable: "true"
155+
traefik.http.routers.well-known.rule: Host(`${DOMAIN:-localhost}`) && PathPrefix(`/.well-known`)
156+
traefik.http.routers.well-known.entrypoints: websecure
157+
traefik.http.routers.well-known.tls.certresolver: letsencrypt
158+
traefik.http.services.well-known.loadbalancer.server.port: "80"
159+
networks: [openmapx]
160+
restart: unless-stopped
161+
deploy:
162+
resources:
163+
limits:
164+
memory: 32m
165+
147166
# Core Infrastructure
148167

149168
postgis:

infra/docker/manage.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ profile_to_services() {
104104
case "$1" in
105105
core) echo "postgis redis" ;;
106106
proxy) echo "traefik" ;;
107-
app) echo "api web" ;;
107+
app) echo "api web well-known" ;;
108108
routing) echo "valhalla osrm" ;;
109109
transit) echo "motis otp" ;;
110110
pelias) echo "elasticsearch pelias-api pelias-placeholder pelias-pip" ;;
@@ -126,7 +126,7 @@ is_profile() {
126126

127127
is_service() {
128128
case "$1" in
129-
postgis|redis|traefik|api|web|valhalla|osrm|motis|otp) return 0 ;;
129+
postgis|redis|traefik|api|web|well-known|valhalla|osrm|motis|otp) return 0 ;;
130130
elasticsearch|pelias-api|pelias-placeholder|pelias-pip) return 0 ;;
131131
nominatim|photon|overpass|tileserver|martin) return 0 ;;
132132
*) return 1 ;;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"webcredentials": {
3+
"apps": [
4+
"TEAM_ID.org.openmapx.app"
5+
]
6+
}
7+
}

0 commit comments

Comments
 (0)