Add native Android Google sign-in

This commit is contained in:
SimpleTest 2026-07-23 20:32:29 +03:00
parent 777bd779ef
commit d18470f77a
19 changed files with 1171 additions and 3 deletions

View File

@ -125,7 +125,9 @@ GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
# Optional Google OpenID Connect registration and sign-in. Leave both empty # 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 # https://YOUR_PHX_HOST/auth/google/callback
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=

View File

@ -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 `GOOGLE_OAUTH_BASE_URL` and the Google HTTP timeout variables empty outside the
isolated protocol drill. 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 ### Optional verified GitHub linking
Create a GitHub OAuth App with this exact callback URL for the active public Create a GitHub OAuth App with this exact callback URL for the active public

View File

@ -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 Disabling the current device removes its server registration and unregisters
the Firebase Installation; registration can be enabled again explicitly. 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 ## Verified build configuration
- Android Gradle Plugin 9.3.0 - 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 source in the Phoenix environment; never put that private JSON in the Android
build. 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 ```sh
./scripts/android-release-build.sh ./scripts/android-release-build.sh
``` ```

View File

@ -307,8 +307,11 @@ tasks.withType<JavaCompile>().configureEach {
dependencies { dependencies {
implementation("androidx.activity:activity:1.13.0") 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.fragment:fragment:1.8.9")
implementation("androidx.webkit:webkit:1.16.0") 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(platform("com.google.firebase:firebase-bom:34.16.0"))
implementation("com.google.firebase:firebase-messaging") implementation("com.google.firebase:firebase-messaging")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View File

@ -11,6 +11,7 @@ import android.net.Uri;
import android.net.http.SslError; import android.net.http.SslError;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.CancellationSignal;
import android.util.Log; import android.util.Log;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.webkit.CookieManager; import android.webkit.CookieManager;
@ -29,15 +30,31 @@ import androidx.activity.ComponentActivity;
import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; 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.WebMessageCompat;
import androidx.webkit.WebViewCompat; import androidx.webkit.WebViewCompat;
import androidx.webkit.WebViewFeature; import androidx.webkit.WebViewFeature;
import com.google.firebase.messaging.FirebaseMessaging; 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.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.UUID; 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.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@ -54,6 +71,10 @@ public final class MainActivity extends ComponentActivity {
private PendingNativeTracking pendingNativeTracking; private PendingNativeTracking pendingNativeTracking;
private AlertDialog pageLoadErrorDialog; private AlertDialog pageLoadErrorDialog;
private boolean mainFrameLoadFailed; private boolean mainFrameLoadFailed;
private CredentialManager credentialManager;
private ExecutorService nativeGoogleNetworkExecutor;
private CancellationSignal nativeGoogleCancellationSignal;
private final AtomicBoolean nativeGoogleRunning = new AtomicBoolean(false);
@Override @Override
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@ -61,6 +82,8 @@ public final class MainActivity extends ComponentActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG); trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG);
credentialManager = CredentialManager.create(this);
nativeGoogleNetworkExecutor = Executors.newSingleThreadExecutor();
locationPermissionLauncher = registerForActivityResult( locationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultContracts.RequestMultiplePermissions(),
result -> { result -> {
@ -210,6 +233,11 @@ public final class MainActivity extends ComponentActivity {
protected void onDestroy() { protected void onDestroy() {
denyPendingLocation(); denyPendingLocation();
dismissPageLoadError(); dismissPageLoadError();
cancelNativeGoogleRequest();
if (nativeGoogleNetworkExecutor != null) {
nativeGoogleNetworkExecutor.shutdownNow();
}
if (webView != null) { if (webView != null) {
webView.stopLoading(); webView.stopLoading();
@ -352,6 +380,14 @@ public final class MainActivity extends ComponentActivity {
disablePush(); disablePush();
} else if ("push_token_request".equals(action)) { } else if ("push_token_request".equals(action)) {
dispatchPendingPushToken(); 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 { } else {
dispatchNativeTrackingError(); 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<GetCredentialResponse, GetCredentialException>() {
@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<String> setCookies, Runnable onStored) {
storeCookieAt(setCookies, 0, onStored);
}
private void storeCookieAt(
java.util.List<String> 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<Void, ClearCredentialException>() {
@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() { private void startPendingNativeTracking() {
PendingNativeTracking pending = pendingNativeTracking; PendingNativeTracking pending = pendingNativeTracking;
pendingNativeTracking = null; pendingNativeTracking = null;

View File

@ -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<String> setCookies) {
LinkedHashMap<String, String> values = new LinkedHashMap<>();
addCookiePairs(values, original, false);
for (String setCookie : setCookies) {
addCookiePairs(values, setCookie, true);
}
StringBuilder merged = new StringBuilder();
for (Map.Entry<String, String> 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<String, String> 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<String> setCookieHeaders(HttpURLConnection connection) {
ArrayList<String> values = new ArrayList<>();
for (Map.Entry<String, List<String>> 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<String> setCookies;
Preparation(
String nonce,
String serverClientId,
String cookie,
List<String> setCookies
) {
this.nonce = nonce;
this.serverClientId = serverClientId;
this.cookie = cookie;
this.setCookies = List.copyOf(setCookies);
}
}
static final class Completion {
final String location;
final List<String> setCookies;
Completion(String location, List<String> 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<String> setCookies;
Response(int status, String body, String location, List<String> setCookies) {
this.status = status;
this.body = body;
this.location = location;
this.setCookies = setCookies;
}
}
}

View File

@ -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);
}
}

View File

@ -121,6 +121,81 @@ window.addEventListener("phx:native-tracking-stop", () => {
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "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 // connect if there are any LiveViews on the page
liveSocket.connect() liveSocket.connect()

View File

@ -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 service-account JSON is a server secret and must never be passed into the
Android build. 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: Check an environment without printing its secret values:
```bash ```bash

View File

@ -17,6 +17,9 @@ defmodule WhoNeedHelp.GoogleAuth do
@callback authorize_url(String.t()) :: @callback authorize_url(String.t()) ::
{:ok, %{url: String.t(), session_params: map()}} | {:error, term()} {:ok, %{url: String.t(), session_params: map()}} | {:error, term()}
@callback callback(String.t(), map(), map()) :: {:ok, identity()} | {: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?() def enabled?, do: adapter().enabled?()
@ -25,6 +28,11 @@ defmodule WhoNeedHelp.GoogleAuth do
def callback(redirect_uri, params, session_params), def callback(redirect_uri, params, session_params),
do: adapter().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 defp adapter do
Application.get_env( Application.get_env(
:who_need_help, :who_need_help,

View File

@ -3,7 +3,7 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
@behaviour WhoNeedHelp.GoogleAuth @behaviour WhoNeedHelp.GoogleAuth
alias Assent.Strategy.Google alias Assent.Strategy.{Google, OIDC}
@impl true @impl true
def enabled?, do: not is_nil(provider_config()) def enabled?, do: not is_nil(provider_config())
@ -30,6 +30,35 @@ defmodule WhoNeedHelp.GoogleAuth.AssentAdapter do
end end
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( def normalize_identity(
%{ %{
"sub" => provider_uid, "sub" => provider_uid,

View File

@ -10,7 +10,9 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2] import WhoNeedHelpWeb.UserAuth, only: [require_sudo_mode: 2]
@session_key "google_auth_flow" @session_key "google_auth_flow"
@native_session_key "native_google_auth_flow"
@supported_locales ~w(en uk ru) @supported_locales ~w(en uk ru)
@native_flows ~w(login register link)
plug :put_no_store plug :put_no_store
plug :require_sudo_mode when action in [:start_link, :disconnect] plug :require_sudo_mode when action in [:start_link, :disconnect]
@ -219,6 +221,83 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
end end
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 defp start_flow(conn, flow, extra, failure_path) do
if GoogleAuth.enabled?() do if GoogleAuth.enabled?() do
case GoogleAuth.authorize_url(callback_url(conn)) do case GoogleAuth.authorize_url(callback_url(conn)) do
@ -246,6 +325,26 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
end end
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 defp finish_flow(conn, "login", flow, identity_attrs) do
case Accounts.login_user_by_google(identity_attrs) do case Accounts.login_user_by_google(identity_attrs) do
{:ok, user} -> {:ok, user} ->
@ -385,4 +484,16 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
defp error_name(_error), do: "unknown_error" defp error_name(_error), do: "unknown_error"
defp put_no_store(conn, _opts), do: put_resp_header(conn, "cache-control", "no-store") 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 end

View File

@ -30,6 +30,10 @@
as={:google_registration} as={:google_registration}
action={~p"/auth/google/register"} action={~p"/auth/google/register"}
id="google_registration_form" id="google_registration_form"
data-native-google-flow="register"
data-native-google-error={
gettext("Google sign-up could not be completed. Please try again.")
}
> >
<input <input
type="hidden" type="hidden"

View File

@ -74,6 +74,10 @@
as={:google_login} as={:google_login}
action={~p"/auth/google/login"} action={~p"/auth/google/login"}
id="google_login_form" id="google_login_form"
data-native-google-flow="login"
data-native-google-error={
gettext("Google sign-in could not be completed. Please try again.")
}
> >
<.google_auth_button label={gettext("Continue with Google")} /> <.google_auth_button label={gettext("Continue with Google")} />
</.form> </.form>

View File

@ -44,6 +44,8 @@
action={~p"/auth/google/link"} action={~p"/auth/google/link"}
id="google_link_form" id="google_link_form"
class="mt-4" class="mt-4"
data-native-google-flow="link"
data-native-google-error={gettext("Google could not be connected. Please try again.")}
> >
<.google_auth_button <.google_auth_button
label={gettext("Connect Google account")} label={gettext("Connect Google account")}

View File

@ -29,6 +29,7 @@ defmodule WhoNeedHelpWeb.Router do
pipeline :mobile do pipeline :mobile do
plug :accepts, ["json"] plug :accepts, ["json"]
plug :fetch_session plug :fetch_session
plug :fetch_live_flash
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers, @secure_browser_headers plug :put_secure_browser_headers, @secure_browser_headers
plug :put_content_security_policy plug :put_content_security_policy
@ -68,6 +69,8 @@ defmodule WhoNeedHelpWeb.Router do
scope "/mobile", WhoNeedHelpWeb do scope "/mobile", WhoNeedHelpWeb do
pipe_through :mobile pipe_through :mobile
post "/auth/google/prepare", GoogleAuthController, :native_prepare
post "/auth/google/complete", GoogleAuthController, :native_complete
post "/push-devices", MobilePushDeviceController, :create post "/push-devices", MobilePushDeviceController, :create
post "/tracking/:assignment_id/position", MobileTrackingController, :update post "/tracking/:assignment_id/position", MobileTrackingController, :update
post "/tracking/:assignment_id/stop", MobileTrackingController, :stop post "/tracking/:assignment_id/stop", MobileTrackingController, :stop

View File

@ -43,4 +43,27 @@ defmodule WhoNeedHelp.GoogleAuthFake do
}} }}
end end
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 end

View File

@ -1,5 +1,5 @@
defmodule WhoNeedHelp.GoogleAuthTest do defmodule WhoNeedHelp.GoogleAuthTest do
use ExUnit.Case, async: true use ExUnit.Case, async: false
alias WhoNeedHelp.GoogleAuth.AssentAdapter alias WhoNeedHelp.GoogleAuth.AssentAdapter
@ -68,4 +68,88 @@ defmodule WhoNeedHelp.GoogleAuthTest do
"email_verified" => true "email_verified" => true
}) })
end 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 end

View File

@ -36,6 +36,169 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
assert %{"flow" => "register", "locale" => "en"} = get_session(conn, "google_auth_flow") assert %{"flow" => "register", "locale" => "en"} = get_session(conn, "google_auth_flow")
end 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 test "registers, confirms, links, and logs in a new verified Google user", %{conn: conn} do
email = unique_user_email() email = unique_user_email()
@ -378,4 +541,15 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
assert actor_id == user.id assert actor_id == user.id
assert target_id == identity.id assert target_id == identity.id
end 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 end