Skip to content

Endurain oauth#1356

Open
AquaWolf wants to merge 8 commits into
jonasoreland:masterfrom
AquaWolf:endurain-oauth
Open

Endurain oauth#1356
AquaWolf wants to merge 8 commits into
jonasoreland:masterfrom
AquaWolf:endurain-oauth

Conversation

@AquaWolf

Copy link
Copy Markdown
Contributor

Implement Endurain Token Refresh and Robust OAuth2 Support

Related Issue
Closes #1340 (Fixes Endurain upload and session connection rotation failures).

Summary of Changes
This PR implements:

  • token refresh for normal password user login
  • standard OAuth2 / OAuth2.1 compliant token refresh and Refresh Token Rotation (RTR) for the Endurain integration in RunnerUp
  • Login Activity for the OAuth workflow
  • user password mfa login option

How to test these changes
If you like you can test the changes against the demo instance of endurain:
https://docs.endurain.com/#try-the-demo

@gerhardol gerhardol requested a review from Copilot May 20, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a more robust authentication flow for the Endurain synchronizer, including a dedicated WebView-based OAuth/SSO login with PKCE and new token refresh handling, plus UX changes to support MFA and alternate login paths.

Changes:

  • Added EndurainLoginActivity (WebView) and PKCEUtil to support PKCE-based SSO/OAuth login and token capture.
  • Extended auth handling in SyncManager/Synchronizer to support MFA and to better handle multi-step authentication outcomes.
  • Updated EndurainSynchronizer to persist/access refresh + CSRF tokens, refresh tokens on demand, and trigger refresh on 401 during upload.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
app/src/main/org/runnerup/view/EndurainLoginActivity.java New WebView-based Endurain login flow with PKCE and token exchange.
app/src/main/org/runnerup/export/util/PKCEUtil.java New helper to generate PKCE verifier/challenge.
app/src/main/org/runnerup/export/SyncManager.java Auth flow updates: MFA prompt, NEED_AUTH handling changes, Endurain “Web Login” option, logging.
app/src/main/org/runnerup/export/Synchronizer.java Adds MFA to AuthMethod.
app/src/main/org/runnerup/export/EndurainSynchronizer.java Adds refresh/CSRF token handling, refresh endpoint support, OAuth intent/result wiring, upload 401->refresh behavior.
app/build.gradle Bumps Material Components dependency.
app/AndroidManifest.xml Registers EndurainLoginActivity.
Comments suppressed due to low confidence (1)

app/src/main/org/runnerup/export/EndurainSynchronizer.java:434

  • When refreshToken() fails and returns NEED_AUTH, access_token is left unchanged. Since connect() returns OK whenever access_token != null, the synchronizer can get stuck in a state where it never prompts for re-auth and keeps using an invalid token. Clear access_token (and likely csrf_token) when refresh fails / before returning NEED_AUTH.
  public Status refreshToken() {
    if (TextUtils.isEmpty(refresh_token)) {
      return getNeedAuthStatus();
    }

    String endpoint = getEndpoint(REFRESH_URL_PATH);

    try {
      OkHttpClient client = new OkHttpClient();
      RequestBody body = RequestBody.create(MediaType.parse("application/json"), "{}");
      Request request = new Request.Builder()
          .url(endpoint)
          .addHeader("X-Client-Type", "mobile")
          .addHeader("Authorization", "Bearer " + refresh_token)
          .post(body)
          .build();

      try (Response response = client.newCall(request).execute()) {
        String responseBody = response.body() != null ? response.body().string() : "";

        if (response.isSuccessful()) {
          JSONObject obj = new JSONObject(responseBody);
          return parseAuthData(obj);
        }
      }
    } catch (Exception e) {
      Log.e(getName(), "refreshToken: exception during request", e);
    }

    return getNeedAuthStatus();
  }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if (mDB != null) {
DBHelper.closeDB(mDB);
}
executor.shutdown();
Comment on lines +403 to +412
authCallback = originalCallback;
authSynchronizer = synchronizer;

mSpinner.show();

executor.execute(() -> {
Status newStatus = synchronizer.connect();
mActivity.runOnUiThread(() -> handleAuth(originalCallback, synchronizer, newStatus.authMethod));
});

Comment on lines +514 to +530
.setNeutralButton(
(authSynchronizer.getName().equals(EndurainSynchronizer.NAME))
? "Web Login" : "Skip",
(dialog, which) ->
{
if (authSynchronizer.getName().equals(EndurainSynchronizer.NAME)) {
try {
//noinspection ConstantConditions
authConfig.put("username", null);
authConfig.put("password", null);
if (authMethod == AuthMethod.USER_PASS_URL) {
authConfig.put(DB.ACCOUNT.URL, urlInput.getText());
}
} catch (JSONException e) {
e.printStackTrace();
}
testUserPass(sync, authConfig);
Comment on lines +368 to +369
.setTitle("Error")
.setMessage("Authentication failed or rate limit exceeded. Please try again later.")
private boolean handleOverride(WebView view, String url, String challenge) {
if (url.contains("sso=success") && url.contains("session_id=")) {
Uri uri = Uri.parse(url);
String sessionId = uri.getQueryParameter("session_id");
Comment on lines +130 to +165
private boolean handleOverride(WebView view, String url, String challenge) {
if (url.contains("sso=success") && url.contains("session_id=")) {
Uri uri = Uri.parse(url);
String sessionId = uri.getQueryParameter("session_id");
exchangeSessionForTokens(sessionId);
return true;
}

if (url.contains("/api/v1/public/idp/login/")) {
if (!url.contains("code_challenge=" + challenge)) {

Uri baseUri = Uri.parse(url);
Uri.Builder newUri = baseUri.buildUpon();

newUri.clearQuery();

newUri.appendQueryParameter("code_challenge", challenge);
newUri.appendQueryParameter("code_challenge_method", "S256");

view.loadUrl(newUri.build().toString());
return true;
}
}
return false;
}

private void checkUrlAndExtractTokens(WebView view, String url, String challenge) {
if (isFinishing) return;

if (url.contains("sso=success") && url.contains("session_id=")) {
view.stopLoading();
Uri uri = Uri.parse(url);
String sessionId = uri.getQueryParameter("session_id");
exchangeSessionForTokens(sessionId);
return;
}
Comment on lines +226 to +228
super.onDestroy();
isFinishing = true;
executor.shutdown();
Comment on lines +386 to +393
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful()) {
s = Status.OK;
} else if (response.code() == 401) {
s = Status.NEED_REFRESH;
} else {
s = Status.ERROR;
}

@gerhardol gerhardol left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some copilot comments that seem reasonable.
The changes seem reasonable, will look into more details later.

Someone else using Endurain that can test?

Squash at merge?

Comment thread common/src/main/res/values-de/strings.xml
Comment thread app/src/main/org/runnerup/view/EndurainLoginActivity.java Outdated
@AquaWolf

AquaWolf commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

If you want you can test it with the demo instance of endurain (https://demo.endurain.com/ admin/admin) but be aware that is a public instance.
The token is is valid for 15 minutes refresh token is valid for 7 days.

I can have a look later again on your findings.

Thanks in advance for reviewing.

@gerhardol

Copy link
Copy Markdown
Collaborator

Someone else using Endurain that can test?

and review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connecting self-hosted Endurian account still doesn't do anything

3 participants