From d18470f77ad046580730f9ef59293549311b1a3a Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 23 Jul 2026 20:32:29 +0300 Subject: [PATCH] Add native Android Google sign-in --- .env.example | 4 +- README.md | 8 + android/README.md | 23 ++ android/app/build.gradle.kts | 3 + .../org/whoneedhelp/mobile/MainActivity.java | 291 ++++++++++++++++++ .../mobile/NativeGoogleAuthClient.java | 267 ++++++++++++++++ .../mobile/NativeGoogleAuthClientTest.java | 49 +++ assets/js/app.js | 75 +++++ docs/operations.md | 8 + lib/who_need_help/google_auth.ex | 8 + .../google_auth/assent_adapter.ex | 31 +- .../controllers/google_auth_controller.ex | 111 +++++++ .../user_registration_html/new.html.heex | 4 + .../user_session_html/new.html.heex | 4 + .../user_settings_html/edit.html.heex | 2 + lib/who_need_help_web/router.ex | 3 + test/support/google_auth_fake.ex | 23 ++ test/who_need_help/google_auth_test.exs | 86 +++++- .../google_auth_controller_test.exs | 174 +++++++++++ 19 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/NativeGoogleAuthClient.java create mode 100644 android/app/src/test/java/org/whoneedhelp/mobile/NativeGoogleAuthClientTest.java diff --git a/.env.example b/.env.example index 7c9bbfd..43e4837 100644 --- a/.env.example +++ b/.env.example @@ -125,7 +125,9 @@ GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS= GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= # Optional Google OpenID Connect registration and sign-in. Leave both empty -# until a Google OAuth Web client exists. Its callback URL must be: +# until a Google OAuth Web client exists. Android Credential Manager obtains +# the public client ID from Phoenix at runtime; never copy the secret into the +# APK or Gradle configuration. The browser callback URL must be: # https://YOUR_PHX_HOST/auth/google/callback GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= diff --git a/README.md b/README.md index 018c2e1..2394d18 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,14 @@ Google from the sudo-protected account settings page instead. Leave `GOOGLE_OAUTH_BASE_URL` and the Google HTTP timeout variables empty outside the isolated protocol drill. +The Android app does not open Google OAuth inside the WebView. Its visible +Google button uses Android Credential Manager, asks this same server for a +session-bound one-time nonce, and sends the resulting ID token directly back to +the server. The server verifies the signature, issuer, audience, expiration, +verified email, and nonce before it reuses the ordinary login, registration, or +account-linking rules. `GOOGLE_OAUTH_CLIENT_SECRET` remains server-only; neither +it nor the ID token is exposed to WebView JavaScript or compiled into the APK. + ### Optional verified GitHub linking Create a GitHub OAuth App with this exact callback URL for the active public diff --git a/android/README.md b/android/README.md index ff3e58e..33e6fe3 100644 --- a/android/README.md +++ b/android/README.md @@ -20,6 +20,15 @@ Help origin. Notification payloads do not contain chat text or exact location. Disabling the current device removes its server registration and unregisters the Firebase Installation; registration can be enabled again explicitly. +Google authentication uses Android Credential Manager rather than an embedded +OAuth user agent. The web page asks the native bridge to begin only after the +user presses the visible Google button. Native code obtains a server-bound +Google ID token with a one-time nonce and posts it directly to the same trusted +Phoenix origin; the token is never returned to WebView JavaScript. The server +client ID is obtained at runtime from the authenticated environment endpoint, +so no Google client secret is compiled into any APK. Signing out also clears +Credential Manager state. + ## Verified build configuration - Android Gradle Plugin 9.3.0 @@ -76,6 +85,20 @@ delivery separately requires `FCM_PROJECT_ID` and exactly one service-account source in the Phoenix environment; never put that private JSON in the Android build. +Google/Firebase setup is environment-specific as well: + +- dev/staging uses package `org.whoneedhelp.mobile.staging`, its stable staging + signing certificate, the dev Web OAuth client, and the dev Firebase project; +- production uses package `org.whoneedhelp.mobile`, the Play-distributed signing + certificate, the production Web OAuth client, and the production Firebase + project; +- `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` stay in that + checkout's single ignored `.env`; only the public client ID is returned at + runtime to Credential Manager; +- the server Web client ID is the audience requested by Credential Manager. + Register the matching Android package/signing certificate in the same Google + project before a real-device sign-in test. + ```sh ./scripts/android-release-build.sh ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3ac24f0..8376d30 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -307,8 +307,11 @@ tasks.withType().configureEach { dependencies { implementation("androidx.activity:activity:1.13.0") + implementation("androidx.credentials:credentials:1.6.0") + implementation("androidx.credentials:credentials-play-services-auth:1.6.0") implementation("androidx.fragment:fragment:1.8.9") implementation("androidx.webkit:webkit:1.16.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0") implementation(platform("com.google.firebase:firebase-bom:34.16.0")) implementation("com.google.firebase:firebase-messaging") testImplementation("junit:junit:4.13.2") diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index bd89ba0..ebc1c4b 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -11,6 +11,7 @@ import android.net.Uri; import android.net.http.SslError; import android.os.Build; import android.os.Bundle; +import android.os.CancellationSignal; import android.util.Log; import android.view.ViewGroup; import android.webkit.CookieManager; @@ -29,15 +30,31 @@ import androidx.activity.ComponentActivity; import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.credentials.ClearCredentialStateRequest; +import androidx.credentials.CredentialManager; +import androidx.credentials.CredentialManagerCallback; +import androidx.credentials.CustomCredential; +import androidx.credentials.GetCredentialRequest; +import androidx.credentials.GetCredentialResponse; +import androidx.credentials.exceptions.ClearCredentialException; +import androidx.credentials.exceptions.GetCredentialException; +import androidx.credentials.exceptions.NoCredentialException; import androidx.webkit.WebMessageCompat; import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewFeature; import com.google.firebase.messaging.FirebaseMessaging; +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption; +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential; +import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import org.json.JSONException; import org.json.JSONObject; @@ -54,6 +71,10 @@ public final class MainActivity extends ComponentActivity { private PendingNativeTracking pendingNativeTracking; private AlertDialog pageLoadErrorDialog; private boolean mainFrameLoadFailed; + private CredentialManager credentialManager; + private ExecutorService nativeGoogleNetworkExecutor; + private CancellationSignal nativeGoogleCancellationSignal; + private final AtomicBoolean nativeGoogleRunning = new AtomicBoolean(false); @Override @SuppressLint("SetJavaScriptEnabled") @@ -61,6 +82,8 @@ public final class MainActivity extends ComponentActivity { super.onCreate(savedInstanceState); trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG); + credentialManager = CredentialManager.create(this); + nativeGoogleNetworkExecutor = Executors.newSingleThreadExecutor(); locationPermissionLauncher = registerForActivityResult( new ActivityResultContracts.RequestMultiplePermissions(), result -> { @@ -210,6 +233,11 @@ public final class MainActivity extends ComponentActivity { protected void onDestroy() { denyPendingLocation(); dismissPageLoadError(); + cancelNativeGoogleRequest(); + + if (nativeGoogleNetworkExecutor != null) { + nativeGoogleNetworkExecutor.shutdownNow(); + } if (webView != null) { webView.stopLoading(); @@ -352,6 +380,14 @@ public final class MainActivity extends ComponentActivity { disablePush(); } else if ("push_token_request".equals(action)) { dispatchPendingPushToken(); + } else if ("google_sign_in".equals(action)) { + startNativeGoogleSignIn( + message.optString("flow", ""), + message.optString("locale", ""), + message.optString("csrf_token", "") + ); + } else if ("google_sign_out".equals(action)) { + clearNativeGoogleCredentialState(); } else { dispatchNativeTrackingError(); } @@ -456,6 +492,261 @@ public final class MainActivity extends ComponentActivity { ); } + private void startNativeGoogleSignIn(String flow, String locale, String csrfToken) { + if ( + !("login".equals(flow) || "register".equals(flow) || "link".equals(flow)) + || csrfToken == null + || csrfToken.isBlank() + || credentialManager == null + || nativeGoogleNetworkExecutor == null + ) { + dispatchNativeGoogleError("invalid_request"); + return; + } + + if (!nativeGoogleRunning.compareAndSet(false, true)) { + return; + } + + String cookie = CookieManager.getInstance().getCookie(BuildConfig.BASE_URL); + if (cookie == null || cookie.isBlank()) { + nativeGoogleRunning.set(false); + dispatchNativeGoogleError("session_unavailable"); + return; + } + + nativeGoogleNetworkExecutor.execute(() -> { + try { + NativeGoogleAuthClient.Preparation preparation = + NativeGoogleAuthClient.prepare( + BuildConfig.BASE_URL, + cookie, + csrfToken, + flow, + locale, + (int) BuildConfig.TRACKING_HTTP_TIMEOUT_MS + ); + + runOnUiThread(() -> { + storeCookies( + preparation.setCookies, + () -> requestNativeGoogleCredential(preparation, csrfToken) + ); + }); + } catch (Exception exception) { + Log.w(LOG_TAG, "Native Google sign-in preparation failed", exception); + finishNativeGoogleWithError("preparation_failed"); + } + }); + } + + private void requestNativeGoogleCredential( + NativeGoogleAuthClient.Preparation preparation, + String csrfToken + ) { + cancelNativeGoogleRequest(); + nativeGoogleCancellationSignal = new CancellationSignal(); + + GetSignInWithGoogleOption option = + new GetSignInWithGoogleOption.Builder(preparation.serverClientId) + .setNonce(preparation.nonce) + .build(); + GetCredentialRequest request = + new GetCredentialRequest.Builder() + .addCredentialOption(option) + .build(); + Executor mainExecutor = this::runOnUiThread; + + credentialManager.getCredentialAsync( + this, + request, + nativeGoogleCancellationSignal, + mainExecutor, + new CredentialManagerCallback() { + @Override + public void onResult(GetCredentialResponse result) { + completeNativeGoogleCredential(result, preparation, csrfToken); + } + + @Override + public void onError(GetCredentialException exception) { + Log.w(LOG_TAG, "Native Google credential request failed", exception); + + if (exception instanceof NoCredentialException) { + finishNativeGoogleWithError("no_google_account"); + } else { + finishNativeGoogleWithError("credential_unavailable"); + } + } + } + ); + } + + private void completeNativeGoogleCredential( + GetCredentialResponse result, + NativeGoogleAuthClient.Preparation preparation, + String csrfToken + ) { + try { + if (!(result.getCredential() instanceof CustomCredential customCredential)) { + finishNativeGoogleWithError("unexpected_credential"); + return; + } + + if ( + !GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL.equals( + customCredential.getType() + ) + ) { + finishNativeGoogleWithError("unexpected_credential"); + return; + } + + GoogleIdTokenCredential googleCredential = + GoogleIdTokenCredential.createFrom(customCredential.getData()); + submitNativeGoogleIdToken( + googleCredential.getIdToken(), + preparation.cookie, + csrfToken + ); + } catch (RuntimeException exception) { + Log.w(LOG_TAG, "Native Google ID token could not be parsed", exception); + finishNativeGoogleWithError("invalid_credential"); + } + } + + private void submitNativeGoogleIdToken( + String idToken, + String cookie, + String csrfToken + ) { + if (nativeGoogleNetworkExecutor == null) { + finishNativeGoogleWithError("completion_failed"); + return; + } + + nativeGoogleNetworkExecutor.execute(() -> { + try { + NativeGoogleAuthClient.Completion completion = + NativeGoogleAuthClient.complete( + BuildConfig.BASE_URL, + cookie, + csrfToken, + idToken, + (int) BuildConfig.TRACKING_HTTP_TIMEOUT_MS + ); + URI destination = + URI.create(BuildConfig.BASE_URL).resolve(completion.location); + + if (!trustedOrigin.matches(destination.toString())) { + finishNativeGoogleWithError("invalid_redirect"); + return; + } + + runOnUiThread(() -> { + storeCookies( + completion.setCookies, + () -> { + nativeGoogleRunning.set(false); + nativeGoogleCancellationSignal = null; + + if (webView != null) { + webView.loadUrl(destination.toString()); + } + } + ); + }); + } catch (Exception exception) { + Log.w(LOG_TAG, "Native Google sign-in completion failed", exception); + finishNativeGoogleWithError("completion_failed"); + } + }); + } + + private void storeCookies(java.util.List setCookies, Runnable onStored) { + storeCookieAt(setCookies, 0, onStored); + } + + private void storeCookieAt( + java.util.List setCookies, + int index, + Runnable onStored + ) { + CookieManager cookieManager = CookieManager.getInstance(); + + if (index >= setCookies.size()) { + cookieManager.flush(); + onStored.run(); + return; + } + + cookieManager.setCookie( + BuildConfig.BASE_URL, + setCookies.get(index), + accepted -> { + if (!Boolean.TRUE.equals(accepted)) { + finishNativeGoogleWithError("session_unavailable"); + return; + } + + storeCookieAt(setCookies, index + 1, onStored); + } + ); + } + + private void finishNativeGoogleWithError(String reason) { + runOnUiThread(() -> { + nativeGoogleRunning.set(false); + cancelNativeGoogleRequest(); + dispatchNativeGoogleError(reason); + }); + } + + private void dispatchNativeGoogleError(String reason) { + if (webView == null) { + return; + } + + webView.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('wnh:native-google-error',{detail:{reason:" + + JSONObject.quote(reason) + + "}}))", + null + ); + } + + private void cancelNativeGoogleRequest() { + CancellationSignal cancellationSignal = nativeGoogleCancellationSignal; + nativeGoogleCancellationSignal = null; + + if (cancellationSignal != null && !cancellationSignal.isCanceled()) { + cancellationSignal.cancel(); + } + } + + private void clearNativeGoogleCredentialState() { + if (credentialManager == null) { + return; + } + + credentialManager.clearCredentialStateAsync( + new ClearCredentialStateRequest(), + null, + this::runOnUiThread, + new CredentialManagerCallback() { + @Override + public void onResult(Void result) { + // The web logout remains the source of truth for the local session. + } + + @Override + public void onError(ClearCredentialException exception) { + Log.w(LOG_TAG, "Native Google credential state could not be cleared", exception); + } + } + ); + } + private void startPendingNativeTracking() { PendingNativeTracking pending = pendingNativeTracking; pendingNativeTracking = null; diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/NativeGoogleAuthClient.java b/android/app/src/main/java/org/whoneedhelp/mobile/NativeGoogleAuthClient.java new file mode 100644 index 0000000..5941878 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/NativeGoogleAuthClient.java @@ -0,0 +1,267 @@ +package org.whoneedhelp.mobile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.json.JSONException; +import org.json.JSONObject; + +final class NativeGoogleAuthClient { + private NativeGoogleAuthClient() { + } + + static Preparation prepare( + String baseUrl, + String cookie, + String csrfToken, + String flow, + String locale, + int timeoutMilliseconds + ) throws IOException, JSONException { + JSONObject body = new JSONObject() + .put("flow", flow) + .put("locale", locale == null ? "" : locale); + Response response = post( + baseUrl, + "/mobile/auth/google/prepare", + cookie, + csrfToken, + body, + timeoutMilliseconds + ); + + if (response.status != HttpURLConnection.HTTP_OK) { + throw new IOException("Native Google preparation failed with HTTP " + response.status); + } + + JSONObject payload = new JSONObject(response.body); + String nonce = payload.optString("nonce", ""); + String serverClientId = payload.optString("server_client_id", ""); + + if (nonce.isBlank() || serverClientId.isBlank()) { + throw new IOException("Native Google preparation response is incomplete"); + } + + return new Preparation( + nonce, + serverClientId, + mergeCookieHeader(cookie, response.setCookies), + response.setCookies + ); + } + + static Completion complete( + String baseUrl, + String cookie, + String csrfToken, + String idToken, + int timeoutMilliseconds + ) throws IOException, JSONException { + JSONObject body = new JSONObject().put("id_token", idToken); + Response response = post( + baseUrl, + "/mobile/auth/google/complete", + cookie, + csrfToken, + body, + timeoutMilliseconds + ); + + if ( + response.status != HttpURLConnection.HTTP_MOVED_TEMP + && response.status != HttpURLConnection.HTTP_SEE_OTHER + ) { + throw new IOException("Native Google completion failed with HTTP " + response.status); + } + + if (response.location == null || response.location.isBlank()) { + throw new IOException("Native Google completion did not return a redirect"); + } + + return new Completion(response.location, response.setCookies); + } + + static String mergeCookieHeader(String original, List setCookies) { + LinkedHashMap values = new LinkedHashMap<>(); + addCookiePairs(values, original, false); + + for (String setCookie : setCookies) { + addCookiePairs(values, setCookie, true); + } + + StringBuilder merged = new StringBuilder(); + for (Map.Entry entry : values.entrySet()) { + if (merged.length() > 0) { + merged.append("; "); + } + merged.append(entry.getKey()).append('=').append(entry.getValue()); + } + return merged.toString(); + } + + private static void addCookiePairs( + LinkedHashMap destination, + String raw, + boolean firstOnly + ) { + if (raw == null || raw.isBlank()) { + return; + } + + String[] parts = raw.split(";"); + int limit = firstOnly ? Math.min(parts.length, 1) : parts.length; + + for (int index = 0; index < limit; index++) { + String part = parts[index].trim(); + int separator = part.indexOf('='); + + if (separator <= 0) { + continue; + } + + String name = part.substring(0, separator).trim(); + String value = part.substring(separator + 1).trim(); + + if ( + !name.isEmpty() + && name.indexOf('\r') < 0 + && name.indexOf('\n') < 0 + && value.indexOf('\r') < 0 + && value.indexOf('\n') < 0 + ) { + destination.put(name, value); + } + } + } + + private static Response post( + String baseUrl, + String path, + String cookie, + String csrfToken, + JSONObject body, + int timeoutMilliseconds + ) throws IOException { + HttpURLConnection connection = null; + + try { + URL url = URI.create(baseUrl).resolve(path).toURL(); + connection = (HttpURLConnection) url.openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(timeoutMilliseconds); + connection.setReadTimeout(timeoutMilliseconds); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("X-CSRF-Token", csrfToken); + connection.setRequestProperty("Cookie", cookie); + connection.setDoOutput(true); + + byte[] encoded = body.toString().getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(encoded.length); + try (OutputStream output = connection.getOutputStream()) { + output.write(encoded); + } + + int status = connection.getResponseCode(); + InputStream responseStream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + String responseBody = ""; + + if (responseStream != null) { + try (InputStream input = responseStream) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4_096]; + int count; + + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + + responseBody = output.toString(StandardCharsets.UTF_8.name()); + } + } + + return new Response( + status, + responseBody, + connection.getHeaderField("Location"), + setCookieHeaders(connection) + ); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private static List setCookieHeaders(HttpURLConnection connection) { + ArrayList values = new ArrayList<>(); + + for (Map.Entry> header : connection.getHeaderFields().entrySet()) { + if ( + header.getKey() != null + && header.getKey().toLowerCase(Locale.ROOT).equals("set-cookie") + && header.getValue() != null + ) { + values.addAll(header.getValue()); + } + } + + return values; + } + + static final class Preparation { + final String nonce; + final String serverClientId; + final String cookie; + final List setCookies; + + Preparation( + String nonce, + String serverClientId, + String cookie, + List setCookies + ) { + this.nonce = nonce; + this.serverClientId = serverClientId; + this.cookie = cookie; + this.setCookies = List.copyOf(setCookies); + } + } + + static final class Completion { + final String location; + final List setCookies; + + Completion(String location, List setCookies) { + this.location = location; + this.setCookies = List.copyOf(setCookies); + } + } + + private static final class Response { + final int status; + final String body; + final String location; + final List setCookies; + + Response(int status, String body, String location, List setCookies) { + this.status = status; + this.body = body; + this.location = location; + this.setCookies = setCookies; + } + } +} diff --git a/android/app/src/test/java/org/whoneedhelp/mobile/NativeGoogleAuthClientTest.java b/android/app/src/test/java/org/whoneedhelp/mobile/NativeGoogleAuthClientTest.java new file mode 100644 index 0000000..86d6e2e --- /dev/null +++ b/android/app/src/test/java/org/whoneedhelp/mobile/NativeGoogleAuthClientTest.java @@ -0,0 +1,49 @@ +package org.whoneedhelp.mobile; + +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Test; + +public final class NativeGoogleAuthClientTest { + @Test + public void mergesRotatedSessionCookieAndPreservesOtherCookies() { + String merged = NativeGoogleAuthClient.mergeCookieHeader( + "_who_need_help_key=old-session; locale=en; theme=dark", + List.of( + "_who_need_help_key=new-session; Path=/; HttpOnly; SameSite=Lax", + "locale=uk; Path=/" + ) + ); + + assertEquals( + "_who_need_help_key=new-session; locale=uk; theme=dark", + merged + ); + } + + @Test + public void ignoresCookieAttributesAndMalformedHeaderValues() { + String merged = NativeGoogleAuthClient.mergeCookieHeader( + "session=original; invalid; =missing-name", + List.of( + "session=rotated; Path=/; Secure", + "bad\r\nheader=value; Path=/", + "second=present; SameSite=Strict" + ) + ); + + assertEquals("session=rotated; second=present", merged); + } + + @Test + public void acceptsAnEmptyInitialCookieHeader() { + String merged = NativeGoogleAuthClient.mergeCookieHeader( + null, + List.of("session=created; Path=/; HttpOnly") + ); + + assertEquals("session=created", merged); + } +} diff --git a/assets/js/app.js b/assets/js/app.js index 7095e06..e58dcf7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -121,6 +121,81 @@ window.addEventListener("phx:native-tracking-stop", () => { window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "stop"})) }) +const configureNativeGoogleAuth = () => { + if (typeof window.WhoNeedHelpAndroid?.postMessage !== "function") return + + const forms = document.querySelectorAll("form[data-native-google-flow]") + + forms.forEach(form => { + if (!(form instanceof HTMLFormElement) || form.dataset.nativeGoogleBound === "true") return + + form.dataset.nativeGoogleBound = "true" + form.addEventListener("submit", event => { + event.preventDefault() + if (form.dataset.nativeGooglePending === "true") return + + const flow = form.dataset.nativeGoogleFlow + if (!["login", "register", "link"].includes(flow)) return + + form.dataset.nativeGooglePending = "true" + form.querySelectorAll("button").forEach(button => { + button.disabled = true + }) + + let status = form.querySelector("[data-native-google-status]") + if (!(status instanceof HTMLElement)) { + status = document.createElement("p") + status.dataset.nativeGoogleStatus = "true" + status.className = "mt-2 text-center text-sm text-base-content/70" + status.setAttribute("role", "status") + status.setAttribute("aria-live", "polite") + form.append(status) + } + status.textContent = "" + + const localeInput = form.querySelector("input[name='google_registration[locale]']") + const locale = localeInput instanceof HTMLInputElement ? localeInput.value : "" + + window.WhoNeedHelpAndroid.postMessage(JSON.stringify({ + action: "google_sign_in", + flow, + locale, + csrf_token: csrfToken + })) + }) + }) +} + +configureNativeGoogleAuth() + +window.addEventListener("wnh:native-google-error", () => { + document.querySelectorAll("form[data-native-google-flow]").forEach(form => { + if (!(form instanceof HTMLFormElement) || form.dataset.nativeGooglePending !== "true") return + + delete form.dataset.nativeGooglePending + form.querySelectorAll("button").forEach(button => { + button.disabled = false + }) + + const status = form.querySelector("[data-native-google-status]") + if (status instanceof HTMLElement) { + status.textContent = + form.dataset.nativeGoogleError || "Google sign-in could not be completed." + status.classList.add("text-error") + } + }) +}) + +document.addEventListener("submit", event => { + const form = event.target + if (!(form instanceof HTMLFormElement)) return + + const action = new URL(form.action, window.location.origin) + if (action.origin === window.location.origin && action.pathname === "/users/log-out") { + window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "google_sign_out"})) + } +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/docs/operations.md b/docs/operations.md index 18dd0ed..25ef828 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -152,6 +152,14 @@ The public Firebase Android values are build configuration; the Base64 FCM service-account JSON is a server secret and must never be passed into the Android build. +For Android Google sign-in, each environment's `GOOGLE_OAUTH_CLIENT_ID` is also +the public server client ID supplied at runtime to Credential Manager. +`GOOGLE_OAUTH_CLIENT_SECRET` never leaves Phoenix. The Android package and +signing-certificate identity must be registered in the matching Google project: +`org.whoneedhelp.mobile.staging` plus the stable staging certificate for dev, +and `org.whoneedhelp.mobile` plus the Play-distributed certificate for +production. Do not add a second Google secret file or Gradle property. + Check an environment without printing its secret values: ```bash diff --git a/lib/who_need_help/google_auth.ex b/lib/who_need_help/google_auth.ex index 1c1a3f4..76fc4d0 100644 --- a/lib/who_need_help/google_auth.ex +++ b/lib/who_need_help/google_auth.ex @@ -17,6 +17,9 @@ defmodule WhoNeedHelp.GoogleAuth do @callback authorize_url(String.t()) :: {:ok, %{url: String.t(), session_params: map()}} | {:error, term()} @callback callback(String.t(), map(), map()) :: {:ok, identity()} | {:error, term()} + @callback client_id() :: {:ok, String.t()} | {:error, term()} + @callback verify_id_token(String.t(), String.t()) :: + {:ok, identity()} | {:error, term()} def enabled?, do: adapter().enabled?() @@ -25,6 +28,11 @@ defmodule WhoNeedHelp.GoogleAuth do def callback(redirect_uri, params, session_params), do: adapter().callback(redirect_uri, params, session_params) + def client_id, do: adapter().client_id() + + def verify_id_token(id_token, nonce), + do: adapter().verify_id_token(id_token, nonce) + defp adapter do Application.get_env( :who_need_help, diff --git a/lib/who_need_help/google_auth/assent_adapter.ex b/lib/who_need_help/google_auth/assent_adapter.ex index 3418d1c..05b7690 100644 --- a/lib/who_need_help/google_auth/assent_adapter.ex +++ b/lib/who_need_help/google_auth/assent_adapter.ex @@ -3,7 +3,7 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do @behaviour WhoNeedHelp.GoogleAuth - alias Assent.Strategy.Google + alias Assent.Strategy.{Google, OIDC} @impl true def enabled?, do: not is_nil(provider_config()) @@ -30,6 +30,35 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do end end + @impl true + def client_id do + with config when is_list(config) <- provider_config(), + {:ok, client_id} <- Keyword.fetch(config, :client_id), + true <- is_binary(client_id) and client_id != "" do + {:ok, client_id} + else + _disabled_or_invalid -> {:error, :provider_disabled} + end + end + + @impl true + def verify_id_token(id_token, nonce) + when is_binary(id_token) and id_token != "" and is_binary(nonce) and nonce != "" do + with config when is_list(config) <- provider_config(), + {:ok, jwt} <- + config + |> Keyword.put(:session_params, %{nonce: nonce}) + |> OIDC.validate_id_token(id_token) do + normalize_identity(jwt.claims) + else + nil -> {:error, :provider_disabled} + {:error, _reason} = error -> error + _invalid -> {:error, :invalid_id_token} + end + end + + def verify_id_token(_id_token, _nonce), do: {:error, :invalid_id_token} + def normalize_identity( %{ "sub" => provider_uid, diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex index d3f1f44..479a4df 100644 --- a/lib/who_need_help_web/controllers/google_auth_controller.ex +++ b/lib/who_need_help_web/controllers/google_auth_controller.ex @@ -10,7 +10,9 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2] @session_key "google_auth_flow" + @native_session_key "native_google_auth_flow" @supported_locales ~w(en uk ru) + @native_flows ~w(login register link) plug :put_no_store plug :require_sudo_mode when action in [:start_link, :disconnect] @@ -219,6 +221,83 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end end + def native_prepare(conn, %{"flow" => flow} = params) when flow in @native_flows do + with true <- GoogleAuth.enabled?(), + {:ok, client_id} <- GoogleAuth.client_id(), + {:ok, flow_context} <- native_flow_context(conn, flow, params) do + nonce = random_url_token(32) + + native_flow = + flow_context + |> Map.put("flow", flow) + |> Map.put("nonce", nonce) + + conn + |> put_session(@native_session_key, native_flow) + |> json(%{nonce: nonce, server_client_id: client_id}) + else + false -> + native_error(conn, :service_unavailable, "google_not_configured") + + {:error, :provider_disabled} -> + native_error(conn, :service_unavailable, "google_not_configured") + + {:error, :authentication_required} -> + native_error(conn, :unauthorized, "authentication_required") + + {:error, :reauthentication_required} -> + native_error(conn, :forbidden, "reauthentication_required") + + {:error, :already_authenticated} -> + native_error(conn, :conflict, "already_authenticated") + + {:error, _reason} -> + native_error(conn, :bad_request, "invalid_google_flow") + end + end + + def native_prepare(conn, _params), + do: native_error(conn, :bad_request, "invalid_google_flow") + + def native_complete(conn, %{"id_token" => id_token}) when is_binary(id_token) do + flow = get_session(conn, @native_session_key) + conn = delete_session(conn, @native_session_key) + + with %{"flow" => flow_name, "nonce" => nonce} <- flow, + true <- flow_name in @native_flows, + {:ok, identity_attrs} <- GoogleAuth.verify_id_token(id_token, nonce) do + finish_flow(conn, flow_name, flow, identity_attrs) + else + nil -> + callback_error( + conn, + flow, + gettext("Google sign-in session expired. Please start again.") + ) + + false -> + callback_error(conn, flow, gettext("Google sign-in session is invalid.")) + + {:error, :email_not_verified} -> + callback_error( + conn, + flow, + gettext("Google did not provide a verified email address.") + ) + + {:error, error} -> + Logger.warning("Native Google ID token verification failed (#{error_name(error)})") + callback_error(conn, flow, gettext("Google sign-in failed. Please try again.")) + end + end + + def native_complete(conn, _params) do + conn + |> delete_session(@native_session_key) + |> put_flash(:error, gettext("Google sign-in failed. Please try again.")) + |> redirect(to: ~p"/users/log-in") + end + defp start_flow(conn, flow, extra, failure_path) do if GoogleAuth.enabled?() do case GoogleAuth.authorize_url(callback_url(conn)) do @@ -246,6 +325,26 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do end end + defp native_flow_context(conn, flow, params) when flow in ["login", "register"] do + if get_in(conn.assigns, [:current_scope, Access.key(:user)]) do + {:error, :already_authenticated} + else + {:ok, %{"locale" => normalize_locale(params["locale"])}} + end + end + + defp native_flow_context(conn, "link", _params) do + case get_in(conn.assigns, [:current_scope, Access.key(:user)]) do + %Accounts.User{} = user -> + if Accounts.sudo_mode?(user, -10), + do: {:ok, %{"user_id" => user.id}}, + else: {:error, :reauthentication_required} + + _missing_user -> + {:error, :authentication_required} + end + end + defp finish_flow(conn, "login", flow, identity_attrs) do case Accounts.login_user_by_google(identity_attrs) do {:ok, user} -> @@ -385,4 +484,16 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do defp error_name(_error), do: "unknown_error" defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store") + + defp native_error(conn, status, code) do + conn + |> put_status(status) + |> json(%{error: code}) + end + + defp random_url_token(length) do + length + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end end diff --git a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex index 80f7bc5..7cc55ae 100644 --- a/lib/who_need_help_web/controllers/user_registration_html/new.html.heex +++ b/lib/who_need_help_web/controllers/user_registration_html/new.html.heex @@ -30,6 +30,10 @@ as={:google_registration} action={~p"/auth/google/register"} id="google_registration_form" + data-native-google-flow="register" + data-native-google-error={ + gettext("Google sign-up could not be completed. Please try again.") + } > <.google_auth_button label={gettext("Continue with Google")} /> diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex index 0c6bb48..5740ffb 100644 --- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -44,6 +44,8 @@ action={~p"/auth/google/link"} id="google_link_form" class="mt-4" + data-native-google-flow="link" + data-native-google-error={gettext("Google could not be connected. Please try again.")} > <.google_auth_button label={gettext("Connect Google account")} diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 7f1b0ab..0984dcf 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -29,6 +29,7 @@ defmodule WhoNeedHelpWeb.Router do pipeline :mobile do plug :accepts, ["json"] plug :fetch_session + plug :fetch_live_flash plug :protect_from_forgery plug :put_secure_browser_headers, @secure_browser_headers plug :put_content_security_policy @@ -68,6 +69,8 @@ defmodule WhoNeedHelpWeb.Router do scope "/mobile", WhoNeedHelpWeb do pipe_through :mobile + post "/auth/google/prepare", GoogleAuthController, :native_prepare + post "/auth/google/complete", GoogleAuthController, :native_complete post "/push-devices", MobilePushDeviceController, :create post "/tracking/:assignment_id/position", MobileTrackingController, :update post "/tracking/:assignment_id/stop", MobileTrackingController, :stop diff --git a/test/support/google_auth_fake.ex b/test/support/google_auth_fake.ex index da368ca..bb46af8 100644 --- a/test/support/google_auth_fake.ex +++ b/test/support/google_auth_fake.ex @@ -43,4 +43,27 @@ defmodule WhoNeedHelp.GoogleAuthFake do }} end end + + @impl true + def client_id, do: {:ok, "google-native-test-client.apps.googleusercontent.com"} + + @impl true + def verify_id_token(id_token, nonce) do + with ["native-test", encoded_nonce, provider_uid, encoded_email, encoded_name] <- + String.split(id_token, ":", parts: 5), + {:ok, token_nonce} <- Base.url_decode64(encoded_nonce, padding: false), + true <- token_nonce == nonce, + {:ok, email} <- Base.url_decode64(encoded_email, padding: false), + {:ok, name} <- Base.url_decode64(encoded_name, padding: false) do + {:ok, + %{ + provider_uid: provider_uid, + email: String.downcase(email), + email_verified: true, + display_name: name + }} + else + _invalid_or_replayed -> {:error, :invalid_id_token} + end + end end diff --git a/test/who_need_help/google_auth_test.exs b/test/who_need_help/google_auth_test.exs index 31cc682..872ed69 100644 --- a/test/who_need_help/google_auth_test.exs +++ b/test/who_need_help/google_auth_test.exs @@ -1,5 +1,5 @@ defmodule WhoNeedHelp.GoogleAuthTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias WhoNeedHelp.GoogleAuth.AssentAdapter @@ -68,4 +68,88 @@ defmodule WhoNeedHelp.GoogleAuthTest do "email_verified" => true }) end + + test "native ID token verification checks signature, audience, expiry, and nonce" do + client_id = "native-client.apps.googleusercontent.com" + client_secret = "native-test-signing-secret-with-sufficient-length" + nonce = "one-time-native-nonce" + now = System.system_time(:second) + original = Application.get_env(:who_need_help, :google_auth) + + on_exit(fn -> + if is_nil(original) do + Application.delete_env(:who_need_help, :google_auth) + else + Application.put_env(:who_need_help, :google_auth, original) + end + end) + + Application.put_env( + :who_need_help, + :google_auth, + client_id: client_id, + client_secret: client_secret, + id_token_signed_response_alg: "HS256", + openid_configuration: %{"issuer" => "https://accounts.google.com"} + ) + + token = + signed_id_token(client_secret, %{ + "iss" => "https://accounts.google.com", + "sub" => "native-subject", + "aud" => client_id, + "iat" => now, + "exp" => now + 300, + "nonce" => nonce, + "email" => "Native@Example.COM", + "email_verified" => true, + "name" => "Native Neighbor" + }) + + assert {:ok, + %{ + provider_uid: "native-subject", + email: "native@example.com", + email_verified: true, + display_name: "Native Neighbor" + }} = AssentAdapter.verify_id_token(token, nonce) + + assert {:error, _reason} = AssentAdapter.verify_id_token(token, "different-nonce") + + wrong_audience = + signed_id_token(client_secret, %{ + "iss" => "https://accounts.google.com", + "sub" => "native-subject", + "aud" => "another-client.apps.googleusercontent.com", + "iat" => now, + "exp" => now + 300, + "nonce" => nonce, + "email" => "native@example.com", + "email_verified" => true + }) + + assert {:error, _reason} = AssentAdapter.verify_id_token(wrong_audience, nonce) + + expired = + signed_id_token(client_secret, %{ + "iss" => "https://accounts.google.com", + "sub" => "native-subject", + "aud" => client_id, + "iat" => now - 600, + "exp" => now - 300, + "nonce" => nonce, + "email" => "native@example.com", + "email_verified" => true + }) + + assert {:error, _reason} = AssentAdapter.verify_id_token(expired, nonce) + end + + defp signed_id_token(secret, claims) do + secret + |> JOSE.JWK.from_oct() + |> JOSE.JWT.sign(%{"alg" => "HS256"}, claims) + |> JOSE.JWS.compact() + |> elem(1) + end end diff --git a/test/who_need_help_web/controllers/google_auth_controller_test.exs b/test/who_need_help_web/controllers/google_auth_controller_test.exs index 398ca21..1e95554 100644 --- a/test/who_need_help_web/controllers/google_auth_controller_test.exs +++ b/test/who_need_help_web/controllers/google_auth_controller_test.exs @@ -36,6 +36,169 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert %{"flow" => "register", "locale" => "en"} = get_session(conn, "google_auth_flow") end + test "prepares a session-bound native Google login without exposing a client secret", %{ + conn: conn + } do + conn = + conn + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "login", "locale" => "uk"}) + + assert %{ + "nonce" => nonce, + "server_client_id" => "google-native-test-client.apps.googleusercontent.com" + } = json_response(conn, 200) + + assert is_binary(nonce) + assert byte_size(nonce) >= 40 + + assert %{ + "flow" => "login", + "locale" => "uk", + "nonce" => ^nonce + } = get_session(conn, "native_google_auth_flow") + + refute response(conn, 200) =~ "secret" + end + + test "native Google login consumes its nonce and signs in a linked identity", %{conn: conn} do + user = user_fixture() + + assert {:ok, _identity} = + Accounts.link_google_identity(user, %{ + provider_uid: "native-returning-google", + email: user.email, + email_verified: true, + display_name: user.display_name + }) + + prepared = + conn + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "login"}) + + nonce = json_response(prepared, 200)["nonce"] + + completed = + prepared + |> recycle() + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/complete", %{ + "id_token" => + native_id_token( + nonce, + "native-returning-google", + user.email, + user.display_name + ) + }) + + assert redirected_to(completed) == ~p"/" + assert get_session(completed, :user_token) + assert is_nil(get_session(completed, "native_google_auth_flow")) + + replay = + completed + |> recycle() + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/complete", %{ + "id_token" => + native_id_token( + nonce, + "native-returning-google", + user.email, + user.display_name + ) + }) + + assert redirected_to(replay) == ~p"/users/log-in" + assert Phoenix.Flash.get(replay.assigns.flash, :error) =~ "session expired" + end + + test "native Google registration continues through the ordinary terms confirmation", %{ + conn: conn + } do + email = unique_user_email() + + prepared = + conn + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "register", "locale" => "ru"}) + + nonce = json_response(prepared, 200)["nonce"] + + completed = + prepared + |> recycle() + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/complete", %{ + "id_token" => native_id_token(nonce, "native-new-google", email, "Native Neighbor") + }) + + assert redirected_to(completed) == ~p"/auth/google/complete" + refute Accounts.get_user_by_email(email) + refute get_session(completed, :user_token) + + html = + completed + |> recycle() + |> delete_req_header("accept") + |> get(~p"/auth/google/complete") + |> html_response(200) + + assert html =~ "Create your Who Need Help account" + assert html =~ email + end + + test "native Google account linking still requires the signed-in sudo owner", %{conn: conn} do + unauthenticated = + conn + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "link"}) + + assert %{"error" => "authentication_required"} = json_response(unauthenticated, 401) + + user = user_fixture() + + prepared = + conn + |> log_in_user(user) + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "link"}) + + nonce = json_response(prepared, 200)["nonce"] + + completed = + prepared + |> recycle() + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/complete", %{ + "id_token" => + native_id_token(nonce, "native-linked-google", "linked@example.com", "Linked") + }) + + assert redirected_to(completed) == ~p"/users/settings" + assert Phoenix.Flash.get(completed.assigns.flash, :info) =~ "connected" + + identity = + Repo.get_by!(AuthIdentity, provider: :google, provider_uid: "native-linked-google") + + assert identity.user_id == user.id + end + + test "native Google flow rejects use while a local account is already signed in", %{conn: conn} do + user = user_fixture() + + conn = + conn + |> log_in_user(user) + |> put_req_header("accept", "application/json") + |> post(~p"/mobile/auth/google/prepare", %{"flow" => "login"}) + + assert %{"error" => "already_authenticated"} = json_response(conn, 409) + assert is_nil(get_session(conn, "native_google_auth_flow")) + end + test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do email = unique_user_email() @@ -378,4 +541,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do assert actor_id == user.id assert target_id == identity.id end + + defp native_id_token(nonce, provider_uid, email, name) do + [ + "native-test", + Base.url_encode64(nonce, padding: false), + provider_uid, + Base.url_encode64(email, padding: false), + Base.url_encode64(name, padding: false) + ] + |> Enum.join(":") + end end