From ac59548337f3c071dea5201554a49ffb02d309d0 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 15 Jun 2026 23:49:47 +0700 Subject: [PATCH] feat(android): Google OAuth via system browser + deep link for Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background: Google blocks OAuth in embedded WebView (403 disallowed_useragent). Solution: open Google login in the Android system browser, then deep-link back to the Tauri app via custom scheme after callback. Changes: - Tauri: add tauri-plugin-opener + tauri-plugin-deep-link to Cargo.toml - Tauri: register plugins in lib.rs, add capabilities - Web: auth-form.tsx Google button uses openUrl() via @tauri-apps/plugin-opener on Tauri (opens in system browser), falls back to window.location.href - Web: add lib/tauri.ts for isTauri() detection + lazy opens - API: /auth/google accepts ?platform=tauri → encodes into OAuth state param - API: /auth/google/callback decodes state → if tauri, renders HTML page that deep-links back via zeavisedu:// scheme; if web, 302 redirect - Android: patch script adds deep link intent filter for zeavisedu:// scheme Co-Authored-By: Claude --- apps/api/src/routes/auth.ts | 79 +++++++++++++++----- apps/tauri/Cargo.toml | 2 + apps/tauri/capabilities/default.json | 5 +- apps/tauri/scripts/patch-android-manifest.sh | 39 ++++++++-- apps/tauri/src/lib.rs | 2 + apps/web/src/components/auth-form.tsx | 31 +++++--- apps/web/src/lib/tauri.ts | 25 +++++++ bun.lock | 10 +++ package.json | 6 +- 9 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/lib/tauri.ts diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index ada279f..6a80ddc 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -55,9 +55,6 @@ async function exchangeGoogleCode(code: string): Promise { } function decodeGoogleIdToken(idToken: string): GoogleIdPayload { - // JWT: header.payload.signature — we only need the payload - // Google's id_token is verified via the token endpoint (direct server-to-server), - // so we can safely decode without verifying the signature here. const parts = idToken.split('.'); if (parts.length !== 3) { throw new Error('Invalid id_token format'); @@ -66,6 +63,41 @@ function decodeGoogleIdToken(idToken: string): GoogleIdPayload { return JSON.parse(payload); } +/** + * Render a page for the Android system browser that redirects back to the + * Tauri app via a custom scheme (zeavisedu://). The app's AndroidManifest + * must register an intent filter for this scheme. + */ +function renderTauriDeepLinkPage(targetUrl: string): Response { + // Rewrite https://... to zeavisedu://... for the custom scheme + const deepLink = targetUrl.replace(/^https?:\/\//, 'zeavisedu://'); + const html = ` + +Kembali ke ZeaVis Edu + +
+

Login berhasil!
Kembali ke aplikasi...

+Buka ZeaVis Edu +

Jika tombol tidak berfungsi, salin URL ini:
${deepLink.replace(/

+
+ +`; + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html;charset=utf-8' }, + }); +} + +function resolvePlatform(stateRaw: string | undefined): string { + try { + if (stateRaw) { + const parsed = JSON.parse(Buffer.from(stateRaw, 'base64url').toString('utf-8')); + return parsed.platform ?? 'web'; + } + } catch { /* ignore */ } + return 'web'; +} + function normalizeEmail(email: unknown) { return typeof email === 'string' ? email.trim().toLowerCase() : ''; } @@ -171,12 +203,15 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) set.headers['Set-Cookie'] = clearSessionCookie(request.headers); return { ok: true }; }) - .get('/google', ({ set }) => { + .get('/google', ({ query, set }) => { if (!env.googleOAuthEnabled) { set.status = 404; return { error: 'Google OAuth is not configured' }; } + const platform = (query as Record).platform ?? 'web'; + const state = Buffer.from(JSON.stringify({ platform })).toString('base64url'); + const params = new URLSearchParams({ client_id: env.googleClientId!, redirect_uri: env.googleRedirectUri!, @@ -184,6 +219,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) scope: 'openid email profile', access_type: 'offline', prompt: 'select_account', + state, }); set.status = 302; @@ -195,13 +231,20 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) return { error: 'Google OAuth is not configured' }; } - const code = (query as Record).code; - const error = (query as Record).error; + const q = query as Record; + const code = q.code; + const error = q.error; + const platform = resolvePlatform(q.state); // User denied or Google returned an error + const makeErrorUrl = (msg: string) => + `${env.webAppUrl}/login?error=${encodeURIComponent(msg)}`; + if (error || !code) { + const url = makeErrorUrl(error ?? 'missing_code'); + if (platform === 'tauri') return renderTauriDeepLinkPage(url); set.status = 302; - set.headers['Location'] = `${env.webAppUrl}/login?error=${encodeURIComponent(error ?? 'missing_code')}`; + set.headers['Location'] = url; return; } @@ -212,15 +255,19 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) idPayload = decodeGoogleIdToken(tokens.id_token); } catch (err) { const msg = err instanceof Error ? err.message : 'Google auth failed'; + const url = makeErrorUrl(msg); + if (platform === 'tauri') return renderTauriDeepLinkPage(url); set.status = 302; - set.headers['Location'] = `${env.webAppUrl}/login?error=${encodeURIComponent(msg)}`; + set.headers['Location'] = url; return; } // Validate email if (!idPayload.email_verified || !idPayload.email) { + const url = makeErrorUrl('Email not verified by Google'); + if (platform === 'tauri') return renderTauriDeepLinkPage(url); set.status = 302; - set.headers['Location'] = `${env.webAppUrl}/login?error=${encodeURIComponent('Email not verified by Google')}`; + set.headers['Location'] = url; return; } @@ -231,19 +278,15 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) try { const db = createDbClient(); - // 1. Try to find user by googleId let user = await db.select().from(users).where(eq(users.googleId, googleId)).limit(1).then(r => r[0] ?? null); - // 2. If not found, try by email (link existing account) if (!user) { user = await db.select().from(users).where(eq(users.email, email)).limit(1).then(r => r[0] ?? null); if (user) { - // Link googleId to existing account await db.update(users).set({ googleId }).where(eq(users.id, user.id)); } } - // 3. Create new user if nothing matched if (!user) { const inserted = await db .insert(users) @@ -253,17 +296,19 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) authCounter.labels('register', 'true').inc(); } - // Create session const token = await createSession(user.id); set.headers['Set-Cookie'] = createSessionCookie(token, request.headers); authCounter.labels('login', 'true').inc(); - // Redirect to web app with token in URL for localStorage fallback + const successUrl = `${env.webAppUrl}/login?token=${encodeURIComponent(token)}`; + if (platform === 'tauri') return renderTauriDeepLinkPage(successUrl); set.status = 302; - set.headers['Location'] = `${env.webAppUrl}/login?token=${encodeURIComponent(token)}`; + set.headers['Location'] = successUrl; } catch (err) { + const url = makeErrorUrl('Database unavailable'); + if (platform === 'tauri') return renderTauriDeepLinkPage(url); set.status = 302; - set.headers['Location'] = `${env.webAppUrl}/login?error=${encodeURIComponent('Database unavailable')}`; + set.headers['Location'] = url; } }); diff --git a/apps/tauri/Cargo.toml b/apps/tauri/Cargo.toml index 190b8ab..a2bbbd2 100644 --- a/apps/tauri/Cargo.toml +++ b/apps/tauri/Cargo.toml @@ -12,5 +12,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", default-features = false, features = ["wry", "common-controls-v6", "dynamic-acl", "x11", "dbus", "custom-protocol"] } +tauri-plugin-opener = "2" +tauri-plugin-deep-link = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/tauri/capabilities/default.json b/apps/tauri/capabilities/default.json index 074b8b5..7e27aef 100644 --- a/apps/tauri/capabilities/default.json +++ b/apps/tauri/capabilities/default.json @@ -3,6 +3,9 @@ "description": "Capability for the main window", "windows": ["main"], "permissions": [ - "core:default" + "core:default", + "opener:default", + "opener:allow-open-url", + "deep-link:default" ] } diff --git a/apps/tauri/scripts/patch-android-manifest.sh b/apps/tauri/scripts/patch-android-manifest.sh index 15de964..7508a22 100755 --- a/apps/tauri/scripts/patch-android-manifest.sh +++ b/apps/tauri/scripts/patch-android-manifest.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Patches the generated AndroidManifest.xml to add CAMERA permission. +# Patches the generated AndroidManifest.xml with: +# 1. CAMERA permission +# 2. Deep link intent filter (zeavisedu:// scheme) for Google OAuth return # Run after `tauri android init` to apply. set -euo pipefail @@ -10,11 +12,34 @@ if [ ! -f "$MANIFEST" ]; then exit 1 fi -if grep -q 'android.permission.CAMERA' "$MANIFEST"; then - echo "CAMERA permission already present in AndroidManifest.xml" - exit 0 +# ── CAMERA permission ────────────────────────────────────────────────── + +if ! grep -q 'android.permission.CAMERA' "$MANIFEST"; then + echo "Adding CAMERA permission to AndroidManifest.xml..." + sed -i 's||\n \n \n |' "$MANIFEST" +else + echo "CAMERA permission already present." fi -echo "Adding CAMERA permission to AndroidManifest.xml..." -sed -i 's||\n \n \n |' "$MANIFEST" -echo "Done." +# ── Deep link intent filter ──────────────────────────────────────────── +# Allows the app to receive zeavisedu:// scheme URLs from the system browser +# (used after Google OAuth completes in external browser on Android) + +DEEP_LINK_FILTER='\ + \ + \ + \ + \ + \ + ' + +if grep -q 'android:scheme="zeavisedu"' "$MANIFEST"; then + echo "Deep link intent filter already present." +else + echo "Adding deep link intent filter to AndroidManifest.xml..." + # Insert before the closing tag of MainActivity + sed -i "s||${DEEP_LINK_FILTER}\n |" "$MANIFEST" + echo "Deep link intent filter added." +fi + +echo "AndroidManifest patched successfully." diff --git a/apps/tauri/src/lib.rs b/apps/tauri/src/lib.rs index 3b48e91..c4fe7a0 100644 --- a/apps/tauri/src/lib.rs +++ b/apps/tauri/src/lib.rs @@ -1,6 +1,8 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_deep_link::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx index 75a51bb..c29fa05 100644 --- a/apps/web/src/components/auth-form.tsx +++ b/apps/web/src/components/auth-form.tsx @@ -1,9 +1,10 @@ -import { FormEvent, useState } from 'react'; +import { FormEvent, useState, useCallback } from 'react'; import { Eye, EyeOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { isTauri, openUrl } from '@/lib/tauri'; type AuthFormProps = { mode: 'login' | 'register'; @@ -25,6 +26,14 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm await onSubmit({ name, email, password }); } + const handleGoogleLogin = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + const platform = isTauri() ? 'tauri' : 'web'; + const base = window.location.origin; + const googleUrl = `${base}/api/v1/auth/google?platform=${platform}`; + await openUrl(googleUrl); + }, []); + return ( @@ -75,17 +84,15 @@ export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubm {googleOAuthEnabled && ( - )} diff --git a/apps/web/src/lib/tauri.ts b/apps/web/src/lib/tauri.ts new file mode 100644 index 0000000..318a2be --- /dev/null +++ b/apps/web/src/lib/tauri.ts @@ -0,0 +1,25 @@ +/** + * Lightweight Tauri environment detection and utilities. + * Avoids importing @tauri-apps/api at module level so the web build + * doesn't bundle Tauri internals. + */ + +let _isTauri: boolean | null = null; + +export function isTauri(): boolean { + if (_isTauri !== null) return _isTauri; + _isTauri = + typeof window !== 'undefined' && + '__TAURI_INTERNALS__' in window; + return _isTauri; +} + +export async function openUrl(url: string): Promise { + if (!isTauri()) { + window.location.href = url; + return; + } + // Lazy-import Tauri opener only in Tauri context + const { openUrl: tauriOpenUrl } = await import('@tauri-apps/plugin-opener'); + await tauriOpenUrl(url); +} diff --git a/bun.lock b/bun.lock index 46f5d18..856ec43 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "zeavis-edu", + "dependencies": { + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-opener": "2.5.4", + }, "devDependencies": { "@moonrepo/cli": "^2.2.5", "typescript": "^6.0.3", @@ -341,6 +345,8 @@ "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], @@ -365,6 +371,10 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], diff --git a/package.json b/package.json index 6532302..447bc31 100644 --- a/package.json +++ b/package.json @@ -13,5 +13,9 @@ "workspaces": [ "apps/*", "packages/*" - ] + ], + "dependencies": { + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-opener": "2.5.4" + } }