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=
|
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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"}))
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user