Add native Android Google sign-in
This commit is contained in:
parent
777bd779ef
commit
d18470f77a
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -307,8 +307,11 @@ tasks.withType<JavaCompile>().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")
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
PendingNativeTracking pending = pendingNativeTracking;
|
||||
pendingNativeTracking = null;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@
|
|||
as={:google_login}
|
||||
action={~p"/auth/google/login"}
|
||||
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")} />
|
||||
</.form>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user