Skip to content

Conversation

dauglyon
Copy link
Collaborator

@dauglyon dauglyon commented Jul 15, 2025

Adds MFA boolean to RemoteIdentity and StoredToken classes, which get stored in Mongo. Adds JWT parsing to extract the AMR claim from the Orcid login token during login. Modifies the token to include MFA status.

A nice summary of all the changes from claude:

Key Changes

Core Implementation:

  • Added mfa field to StoredToken (stored as mfa in MongoDB)
  • Extended RemoteIdentityDetails to capture MFA status from identity providers
  • Updated APIToken to expose MFA status via /api/V2/token endpoint

ORCID Provider Enhancement:

  • Changed scope from /authenticate to openid to receive JWT ID tokens
  • Parse Authentication Method Reference (AMR) claim to detect MFA usage
  • Set mfa=USED when AMR contains "mfa"

Authentication Flow:

  • OAuth login: MFA status determined from identity provider and stored on LOGIN token
  • Password login: MFA status set to uknown (not applicable)
  • Agent/Dev/Service tokens: MFA status set to uknown (no authentication context)

API Response

The /api/V2/token endpoint now returns mfa with tri-state semantics:

  • USED: User authenticated with MFA during token creation (e.g. Orcid with MFA)
  • NOT_USED: User explicitly chose not to use MFA when available (e.g. Orcid without MFA)
  • UNKNOWN: MFA status unknown or not applicable to authentication method (e.g. Google (mfa inspection not supported), a non-member Orcid API client account (openid token not supported), or a dev token)

Database Schema

  • Tokens collection: Added mfa field
  • Users collection: Added mfa field to identity objects

@dauglyon dauglyon assigned dauglyon and unassigned dauglyon Jul 15, 2025
@dauglyon

This comment has been minimized.

dauglyon added 4 commits July 15, 2025 12:05
Update tests to handle MFA field additions and ORCID OpenID Connect changes:
- Fix hash codes in RemoteIdentityTest for new MFA field
- Update ORCID provider tests for openid scope instead of /authenticate
- Add mfaAuthenticated field to API response expectations
- Fix non-ORCID provider test to use null MFA status
- Update identity ordering in user endpoint tests
Copy link

codecov bot commented Jul 16, 2025

Codecov Report

❌ Patch coverage is 94.56522% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.35%. Comparing base (47f6a01) to head (a4a0262).

Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #471      +/-   ##
=============================================
- Coverage      93.37%   93.35%   -0.02%     
- Complexity      2151     2171      +20     
=============================================
  Files            126      127       +1     
  Lines           7558     7634      +76     
  Branches        1184     1207      +23     
=============================================
+ Hits            7057     7127      +70     
- Misses           458      459       +1     
- Partials          43       48       +5     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dauglyon dauglyon marked this pull request as ready for review July 17, 2025 17:17
@dauglyon dauglyon changed the title MFA Support Orcid Provider MFA Support & Token response mfaAuthenticated key Jul 17, 2025
@dauglyon dauglyon changed the title Orcid Provider MFA Support & Token response mfaAuthenticated key [ CDM-243 ] [ CDM-245 ] Orcid Provider MFA Support & Token response mfaAuthenticated key Jul 17, 2025
dauglyon added 2 commits July 17, 2025 12:31
  Move MFA status from being computed from user identities to being stored
  as a boolean field on tokens themselves. This provides per-token MFA
  tracking and eliminates the need for complex identity lookups.
@dauglyon dauglyon changed the title [ CDM-243 ] [ CDM-245 ] Orcid Provider MFA Support & Token response mfaAuthenticated key [ CDM-243 ] [ CDM-245 ] Orcid Provider MFA Support & Token response mfa key Aug 13, 2025
@dauglyon dauglyon requested a review from MrCreosote August 13, 2025 23:02
@dauglyon
Copy link
Collaborator Author

See I need another test or two to have complete coverage, will add.

- Add getMfaStatus helper method to handle null MFA fields in database
- Update token and identity deserialization to use helper method
- Add tests for backward compatibility with existing database records
- Prevents NullPointerException when MFA field is missing from stored data
@dauglyon dauglyon force-pushed the MFAStatus branch 3 times, most recently from 545f705 to e8fdd6a Compare September 8, 2025 21:00
- Update scope in login URL tests from 'openid' to 'openid+%2Fauthenticate'
- Add JWT tokens to tests that were missing them after OpenID Connect changes
- Update getIdentityWithNoJWT test to expect new error when JWT is missing
- Fix error condition tests to provide valid JWTs so they test intended errors
dauglyon and others added 3 commits September 8, 2025 18:10
- Add MFA field to ExternalToken base class with UNKNOWN default
- Remove duplicate MFA handling from APIToken (now inherited)
- Ensures all token endpoints return MFA status for UI compatibility
- Non-ORCID providers default to MFA status UNKNOWN
…lsverifier contract test. this resolves the hardcoded hashcodes causing stochastic test failures depending on execution order/enviroment, test cleanup to follow
Copy link
Member

@MrCreosote MrCreosote left a comment

Choose a reason for hiding this comment

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

Review is already huge so skipping tests until next time

Copy link
Member

Choose a reason for hiding this comment

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

According to codecov there's still some uncovered code, which should be coverable since the orcid interactions are mocked and everything else can be unit tested

Comment on lines +752 to +753
private NewToken login(final UserName userName, final TokenCreationContext tokenCtx,
final MfaStatus mfa) throws AuthStorageException {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
private NewToken login(final UserName userName, final TokenCreationContext tokenCtx,
final MfaStatus mfa) throws AuthStorageException {
private NewToken login(
final UserName userName,
final TokenCreationContext tokenCtx,
final MfaStatus mfa
) throws AuthStorageException {

My rule is either all args on 1 line or each arg on its own line. More readable

Copy link
Member

Choose a reason for hiding this comment

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

You might want to rework this in a similar way to TokenType so that we can change the enum name without breaking things later:

https://github.com/kbase/auth2/blob/47f6a0160a6c3bec09f49a6623b3decb3d647a8d/src/main/java/us/kbase/auth2/lib/token/TokenType.java

} else {
this.email = email.trim();
}
this.mfa = mfa;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.mfa = mfa;
this.mfa = requireNonNull(mfa, "mfa");

int result = 1;
result = prime * result + ((email == null) ? 0 : email.hashCode());
result = prime * result + ((fullname == null) ? 0 : fullname.hashCode());
result = prime * result + ((mfa == null) ? 0 : mfa.name().hashCode());
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
result = prime * result + ((mfa == null) ? 0 : mfa.name().hashCode());
result = prime * result + ((mfa == null) ? 0 : mfa.hashCode());

Getting the hash directly from the enum class is fine, it's done this way in other places in the codebase.

There's a couple other places in the PR where this should be fixed

.append(pre + Fields.IDENTITIES_EMAIL, rid.getEmail())
.append(pre + Fields.IDENTITIES_NAME, rid.getFullname()));
.append(pre + Fields.IDENTITIES_NAME, rid.getFullname())
.append(pre + Fields.IDENTITIES_MFA, rid.getMfa().name()));
Copy link
Member

Choose a reason for hiding this comment

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

This is updating the user document, which I don't think you want to do


@Override
public OptionalsStep withMfa(final MfaStatus mfa) {
this.mfa = mfa;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.mfa = mfa;
this.mfa = requireNonNull(mfa, "mfa");

Comment on lines 3152 to 3155
}


/** Get the external configuration without providing any credentials.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}
/** Get the external configuration without providing any credentials.
}
/** Get the external configuration without providing any credentials.

created = storedToken.getCreationDate().toEpochMilli();
custom = storedToken.getContext().getCustomContext();
// For tokens from non-ORCID providers, MFA status defaults to UNKNOWN
mfa = storedToken.getMfa() != null ? storedToken.getMfa() : MfaStatus.UNKNOWN;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
mfa = storedToken.getMfa() != null ? storedToken.getMfa() : MfaStatus.UNKNOWN;
mfa = storedToken.getMfa()

Can't be null with the other changes in this review

Comment on lines 1188 to 1191
}


}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}
}
}
}

Copy link
Member

@MrCreosote MrCreosote left a comment

Choose a reason for hiding this comment

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

Sorry, hit the submit button too early

private static final Client CLI = ClientBuilder.newClient();

private static final ObjectMapper MAPPER = new ObjectMapper();
private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(OrcIDIdentityProviderFactory.class);
Copy link
Member

Choose a reason for hiding this comment

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

Can you handle logging like this:

private void logInfo(final String format, final Object... params) {
LoggerFactory.getLogger(getClass()).info(format, params);
}

There's some weirdness I've encountered in the past where a static logger could cause (I think) permgen OOMs. Probably won't happen here but just to be safe

(String) m.get("access_token"),
(String) m.get("name"),
(String) m.get("orcid"));
fullName != null ? fullName : "",
Copy link
Member

Choose a reason for hiding this comment

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

Why this change? It's backwards incompatible and all the other providers return null if there's no full name

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

A mistake, artifact from when I was trying to use only one auth call

* to determine if multi-factor authentication was used.
*
* @param jwt the JWT ID token from ORCID (may be null/empty for non-member accounts)
* @return MfaStatus indicating whether MFA was used, UNKNOWN if JWT is missing or unparseable
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @return MfaStatus indicating whether MFA was used, UNKNOWN if JWT is missing or unparseable
* @return MfaStatus indicating whether MFA was used, UNKNOWN if JWT is missing

although from a comment from review # 1, it sounds like missing should throw an error?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I couldn't figure out how to make the error informative to the user and not just crap-out without a lot of changes to other parts of the code. Perhaps an mfa:ERROR state? Right now I'm just printing to logs that the error occurred

Comment on lines +268 to +276
} catch (IllegalArgumentException e) {
// Base64 decoding failed - invalid JWT format
LOGGER.warn("Unable to decode JWT from ORCID: {}", e.getMessage());
throw new IdentityRetrievalException("Unable to decode JWT from ORCID: " + e.getMessage(), e);
} catch (IOException e) {
// JSON parsing failed - malformed payload
LOGGER.warn("Unable to parse JWT payload from ORCID: {}", e.getMessage());
throw new IdentityRetrievalException("Unable to parse JWT payload from ORCID: " + e.getMessage(), e);
}
Copy link
Member

Choose a reason for hiding this comment

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

Can you move these up to specifically catch errors from the lines that throw them? Ensures we don't accidentally mask unexpected errors

@MrCreosote
Copy link
Member

It's been a while since the first time I reviewed it but I get the feeling the updated implementation is much more straightforward

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.

2 participants