Skip to content

Migrating to OAuth Authentication#2374

Open
Silarn wants to merge 11 commits intomasterfrom
dev/oauth-graphql
Open

Migrating to OAuth Authentication#2374
Silarn wants to merge 11 commits intomasterfrom
dev/oauth-graphql

Conversation

@Silarn
Copy link
Copy Markdown
Member

@Silarn Silarn commented Apr 19, 2026

This supplants #2302

This replaces the old API key authorization loop in favor of the newer OAuth authentication system.

For backwards compatibility, it is still able to load and use the old API key if set but will prefer any stored OAuth credentials.

It is still possible to manually enter an API key if desired.

So far as I can tell, all the v1 endpoints are functional with the OAuth token (with the exception of the 'validation' endpoint that requires an API key).

This is also a stepping stone to being able to use the GraphQL and v3 REST APIs which should allow us to implement collections fetching as well...

@Silarn Silarn requested review from Al12rs and Holt59 April 19, 2026 01:14
Comment thread src/settingsdialognexus.h Outdated
Comment on lines +49 to +54
void validateTokens(const NexusOAuthTokens& tokens);
bool persistTokens(const NexusOAuthTokens& tokens);
bool clearTokens();

void onSSOKeyChanged(const QString& key);
void onSSOStateChanged(NexusSSOLogin::States s, const QString& e);
void onTokensReceived(const NexusOAuthTokens& tokens);
void onOAuthStateChanged(NexusOAuthLogin::State s, const QString& message);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we not still have the equivalent of these methods for the API key?
I think these are currently doing a mix for both?

Comment thread src/settings.cpp
Comment thread src/apiuseraccount.cpp Outdated
bool APIUserAccount::isValid() const
{
return !m_key.isEmpty();
return !m_accessToken.isEmpty() && !m_apiKey.isEmpty();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this was probably meant to be an OR

Suggested change
return !m_accessToken.isEmpty() && !m_apiKey.isEmpty();
return !m_accessToken.isEmpty() || !m_apiKey.isEmpty();

Comment thread src/nxmaccessmanager.cpp Outdated
Comment on lines +696 to +719
bool NXMAccessManager::ensureFreshToken()
{
if (!m_tokens) {
log::warn("nexus: no OAuth tokens available");
return false;
}

if (!m_tokens->accessToken.isEmpty()) {
if (!m_tokens->isExpired()) {
return true;
}

const auto refreshed = refreshTokensBlocking(*m_tokens);
if (!refreshed) {
return false;
}

setTokens(*refreshed);
GlobalSettings::setNexusOAuthTokens(*refreshed);
return true;
}

return true;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should add a m_refreshing guard to avoid having multiple API calls refreshing the token simultaneously, we could end up saving the wrong token or weird shenanigans.

Comment thread src/nxmaccessmanager.cpp Outdated
Comment on lines +331 to +350
if (!m_tokens.accessToken.isEmpty()) {
if (!data.contains("sub")) {
setFailure(HardError, QObject::tr("Bad response"));
return;
}

const QString id = data.value("sub").toString();
const QString name = data.value("name").toString();
const auto roles = data.value("membership_roles").toArray();
QStringList validRoles = {"premium", "lifetimepremium", "supporter"};
bool premium = false;
for (auto role : roles) {
QString roleVal = role.toString();
if (validRoles.contains(roleVal)) {
premium = true;
break;
}
}

const int id = data.value("user_id").toInt();
const QString key = data.value("key").toString();
const QString name = data.value("name").toString();
const bool premium = data.value("is_premium").toBool();
if (m_tokens.accessToken.isEmpty()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if (m_tokens.accessToken.isEmpty()) was already checked a few lines above, unless it can have changed in the mentime?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This can probably be folded into an 'else'. We can only reach this point assuming at least one access token or api key is available. So some of these checks are to determine which flow we're using. Of course, if we separate these flows further that isn't needed.

Comment thread src/nxmaccessmanager.cpp Outdated
Comment on lines 915 to 920
void NXMAccessManager::clearTokens()
{
m_validator.cancel();
m_tokens.reset();
emit credentialsReceived(APIUserAccount());
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we revoke the tokens as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Well, they will also expire after a while but I suppose that's probably a good idea.

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 19, 2026

@Al12rs A couple questions. The original code (as well as Vortex when I checked it) indicated there would be a 'token_type' attribute, but when I was getting the values from the response I didn't see that anywhere. Does this still get sent?

Also, are there API limits with OAuth / GraphQL? I didn't see any headers like the V1 API includes. But if they do still exist it would be good to make sure we're accounting for them.

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 19, 2026

From @Al12rs on discord:

The refresh POST in refreshTokensBlocking, the PKCE injection in the login, and some of the expiry/token tracking duplicate stuff that QOAuth2AuthorizationCodeFlow already does for us out of the box (especially on Qt 6.11, PKCE is automatic and the flow can add the bearer header to outbound requests itself).

My preference would be to lean on QOAuth2AuthorizationCodeFlow as the source of truth, keep a single long lived flow instance, seed it from storage on startup, and let it handle refresh and header injection. We'd still need some custom code for the legacy APIKEY fallback and single flight guard, but a lot of the manual HTTP would just go away.

@aglowinthefield 's original PR was based on 2.5.2 and old Qt so was probably mostly accurate for the time. I'll admit I didn't look that much into how those classes had changed over the past several Qt versions. That being said, there's certainly some Qt version checking we don't need to maintain, and I'll take a look at the QOAuth2AuthorizationCodeFlow stuff when I have time.

@Silarn Silarn marked this pull request as draft April 19, 2026 22:37
@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 19, 2026

I wasn't sure how long the token would last but it looks like it expired now and I ran into issues on startup so let me look at this and fix it up. I'll try to clean up the 'login' class considering the above.

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 20, 2026

We're still doing CI on 6.7.3 - I'm using functions added in 6.9.0, so mob will need to be updated to 6.11.0.

@Silarn Silarn force-pushed the dev/oauth-graphql branch from e9eb128 to 66e1fd2 Compare April 20, 2026 06:47
@Silarn Silarn requested a review from Al12rs April 20, 2026 06:54
@Pickysaurus
Copy link
Copy Markdown

Noting original ticket here: #2276
I am in the process of getting an OAuth client ID added for MO2 on the Nexus Mods side.

You will need to add PKCE if you haven't already: https://modding.wiki/en/api/oauth2-guide

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 20, 2026

@Pickysaurus Well the original PR implemented it manually, but implementing PKCE is the default behavior in Qt as of 6.8.

This PR functions, I've been using it.

As of now the applicaition ID is set to default to 'modorganizer2' though it can be set by an environment variable. If you want to use something else let us know.

return envOrDefault("MO2_NEXUS_CLIENT_ID", QStringLiteral("modorganizer2"));

- Moved all OAuth code to NXMAccessManager
- Removed unnecessary manual auth / refresh code
- Ensured we initialize / refresh token on startup
- Use OAuth flow to call API endpoints
@Silarn Silarn force-pushed the dev/oauth-graphql branch from 72bce4c to 87135c1 Compare April 20, 2026 17:53
- Consolidate API header function
- Extend OAuth request functions
- Remove unneeded function
- Wait for auth to finish before doing validate
- Fallback refresh as autorefresh is inconsistent
- Better queueing of auth / validate
Comment thread src/settings.cpp Outdated
Comment on lines +2523 to +2552
bool GlobalSettings::nexusOAuthTokens(NexusOAuthTokens& tokens)
{
// If legacy credential key exists and is not set in the new credentials,
// insert it into the current tokens. In all cases, clear the old credential
// store once parsed.
const auto legacyRaw = getWindowsCredential(NexusLegacyCredentialKey);
if (!legacyRaw.isEmpty()) {
tokens.apiKey = legacyRaw;
setWindowsCredential(NexusLegacyCredentialKey, "");
}
const auto raw = getWindowsCredential(NexusOAuthCredentialKey);
const auto parsed = parseStoredTokens(raw);
if (!parsed && legacyRaw.isEmpty()) {
return false;
} else if (parsed && legacyRaw.isEmpty()) {
tokens = *parsed;
} else if (parsed) {
if (!parsed->apiKey.isEmpty()) {
tokens = *parsed;
} else {
tokens.accessToken = parsed->accessToken;
tokens.refreshToken = parsed->refreshToken;
tokens.expiresAt = parsed->expiresAt;
tokens.tokenType = parsed->tokenType;
tokens.scope = parsed->scope;
setNexusOAuthTokens(tokens);
}
} else {
setNexusOAuthTokens(tokens);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This function is very confusing. From the name I would have no idea it is doing something with the API key.

We should definitely not use OAuth if we are also talking about API key.

I would suggest using nexusAuthCredentials if we have to refer to both OAuth token and API key.

But to be honest, I don't know why we would not have two separate methods. I would have one for API key and one for the token. We can leave the API key where it was before, why migrate it. Unless I'm missing something.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't know. You end up just requiring multiple credential calls or separate storage either way, so you're adding complexity in one direction or the other. But at the end of the day I think I just rolled with the original PR removing the API key altogether initially, and then thought we might need to fetch / generate the key to call the old validate function. So they ended up tied together.

But with the separate users API to validate with, now it functions mostly as a fallback / backwards compatibility measure.

I suppose I can roll back those particular changes and keep both storage methods.

@Pickysaurus
Copy link
Copy Markdown

Your OAuth client data has now been set up on the Nexus Mods side.

  • Client ID: modorganizer2
  • Callback URI : http://127.0.0.1:28635/callback (Note, we had a previous entry registered with port 5000, but I don't know who requested that or when, so this overwrites it)

As previously mentioned, you'll need to use PKCE, which protects the login session from being hijacked.

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 23, 2026

@Pickysaurus As I've stated I believe Qt versions since 6.8 automatically use a PKCE flow, but just to be sure I set it manually here:

m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256);

I'm already using this OAuth login locally. @Al12rs did give us some of this info on Discord. It wasn't clear to me if the secret key was actually being used anywhere. I don't know if you have any info on that.

@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 23, 2026

I'm going to separate the credentials tonight and I think the PR will be ready.

I suppose my only personal critique would be that it might be nice to serve a slightly more visually appealing HTML page on success. It's pretty plain at the moment.

@Pickysaurus
Copy link
Copy Markdown

@Pickysaurus As I've stated I believe Qt versions since 6.8 automatically use a PKCE flow, but just to be sure I set it manually here:

m_NexusOAuth->setPkceMethod(QOAuth2AuthorizationCodeFlow::PkceMethod::S256);

I'm already using this OAuth login locally. @Al12rs did give us some of this info on Discord. It wasn't clear to me if the secret key was actually being used anywhere. I don't know if you have any info on that.

The secret is only used for private applications (i.e. a web server that makes requests on behalf of a user). Mod managers are public apps as they make API requests directly.

Essentially for public apps you use PKCE to verify, whereas private apps don't need that and just send their client secret.

- Functions as fallback / backwards compat
- Fix a couple minor oversights
@Silarn Silarn force-pushed the dev/oauth-graphql branch from a404cee to 33d5378 Compare April 24, 2026 15:31
@Silarn Silarn marked this pull request as ready for review April 24, 2026 15:35
@Silarn
Copy link
Copy Markdown
Member Author

Silarn commented Apr 25, 2026

@Pickysaurus I suddenly find I don't seem to be receiving an expiration time for my OAuth tokens. Is this something that changed when you registered MO2?

Nevermind. I changed around how various functions triggered, and it turns out the tokenChanged signal fires before all of the response data is parsed. It's working if I wait until the 'granted' signal fires.

Silarn added 2 commits April 25, 2026 00:07
- Remove callbacks, implement signal / slots
- Revert isValid since the API key is handled separately
- Add additional callbacks / error handling
- Try to ensure the reply handler is ready
- Make sure validator is properly reset on finish
- Move revalidation check to nexusinterface
  - Stop the API processing until validation clears
  - Avoids request collisions
  - Should restart on revalidation
- Remove autorefresh for now as it isn't working
- Extend refresh time to 5 minutes
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.

4 participants