Skip to content

Conversation

@aleksrozman
Copy link
Contributor

@aleksrozman aleksrozman commented Oct 18, 2025

This change switches out the existing exelon structure from website, where we get short lived tokens, to a mobile app where we get refresh tokens. This was tested on a single subsidiary, so we should verify it across a few to make sure before submitting this change. In theory this would create an indefinite access since we would be performing refreshes on the refresh token daily.

This resolves #150

We need to support legacy clients being upgraded and ensure that the MFA challenge is presented when there is a problem with the refresh token. The only system would provide an invalid auth, but not trigger the MFA. This was tested by corrupting login_data with a bad token.
@aleksrozman
Copy link
Contributor Author

@tronikos providing an InvalidAuth flow does not trigger an MFA in the current home assistant opower integration. This means you get caught in a loop where it tries to reuse the login data which is what would cause the InvalidAuth to begin with. In this PR I've also fixed it so instead of InvalidAuth it triggers an MfaChallenge when there is an authentication issue, which I think would resolve the issue in the latest home assistant. I would propose that if there was an InvalidAuth we might want to consider the login_data to be bad and not re-use it for a subsequent login.

I think your pge integration might not have the same issue since it might respond with an MFA challenge instead of an invalid authentication. However, if the user was to change the password it might follow a different flow since there would be invalid cookies not null. The obvious workaround is simply to delete the integration and restart.

@tronikos
Copy link
Owner

providing an InvalidAuth flow does not trigger an MFA in the current home assistant opower integration

Yes this is WAI. You need to raise MfaChallenge to trigger MFA. If you raise InvalidAuth it will ask you to enter your username and password.

Copy link
Contributor

@PaulSD PaulSD left a comment

Choose a reason for hiding this comment

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

Based on a manual code review: Overall looks good. Just some minor comments/suggestions.

f"https://{self._base_url}/oauth2/v2.0/token",
data={
"grant_type": "authorization_code",
"scope": "openid offline_access " + self._client_id,
Copy link
Contributor

Choose a reason for hiding this comment

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

Does the mobile app include client_id in the scope? That doesn't look right to me. I think it should just be "openid offline_access".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the mobile app is passing the client id into the scope for some reason. When I mess with the fields on how they ought to be, it seems to have broken. I was testing a few things at once, but I noted the importance of client_id and redirect_uri pairing. When you remove this piece from authorization_code it does not work.

data={
"grant_type": "refresh_token",
"response_type": "token",
"scope": "openid offline_access " + self._client_id,
Copy link
Contributor

Choose a reason for hiding this comment

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

Does the mobile app include client_id in the scope? That doesn't look right to me. I think it should just be "openid offline_access".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the mobile app is passing the client id into the scope here too. However, testing confirms we do not need it. I am however leaving it in because that is how at least one of the mobile apps behave.

account for account in result_json.get("data", {}) if account.get("status", "") == "Active"
]

if len(active_accounts) == 0:
Copy link
Contributor

Choose a reason for hiding this comment

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

Might also be helpful to log a message if there are multiple accounts:

if len(active_accounts) > 1:
  _LOGGER.info("Found multiple active accounts, using {active_accounts[0].get("accountNumber", "")}")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, extra debug logging does not hurt to have.

account = active_accounts[0]
account_number = account["accountNumber"]
# set the first active one
return active_accounts[0]
Copy link
Contributor

@PaulSD PaulSD Oct 18, 2025

Choose a reason for hiding this comment

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

Having this return outside of the try is a little weird and hard to follow. Would probably be easier to follow if it was inside the try block.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and implemented.

# Get the account type & state

isResidential = account["isResidential"]
is_residential = account["isResidential"]
Copy link
Contributor

@PaulSD PaulSD Oct 18, 2025

Choose a reason for hiding this comment

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

Should use account.get("isResidential", false), to avoid failures if the isResidential field is removed or renamed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, I was cleaning this code up but did not have a good way to test. Fixed in the latest commit.


isResidential = account["isResidential"]
is_residential = account["isResidential"]
state = account["PremiseInfo"][0]["mainAddress"]["townDetail"]["stateOrProvince"]
Copy link
Contributor

Choose a reason for hiding this comment

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

To avoid failures if these fields are removed/renamed/restructured/etc:

        try:
            state = result_json["PremiseInfo"][0]["mainAddress"]["townDetail"]["stateOrProvince"]
        except (KeyError, IndexError):
            state = None

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed.

# Determine subdomain to use by matching logic found in https://cls.login_domain()/dist/app.js
Exelon._subdomain = cls.primary_subdomain()
if not isResidential or state != "MD":
if not is_residential or state != "MD":
Copy link
Contributor

Choose a reason for hiding this comment

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

This might be a little easier to read?

if not (is_residential and state == "MD"):

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

Minor adjustments to the style to make it easier for others to follow based on the comments in the pull request.
@aleksrozman aleksrozman marked this pull request as ready for review October 18, 2025 23:02
@aleksrozman
Copy link
Contributor Author

Thanks for the review @PaulSD

@tronikos we have multiple subsidiaries tested and confirmed, after several hours the token is holding as well so I have good confidence this will last at least longer than what is there. This seems good enough to start the formal review process/submit so it is ready by next release. I will keep testing through the weekend just in case but we can fix anything that comes up with a followup.

@tronikos tronikos merged commit a4a1dfa into tronikos:main Oct 19, 2025
3 checks passed
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.

Extend the lifetime of Exelon subsidiaries MFA tokens

3 participants