Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
ee7a514
Add openid role and mfa parsing to Orcid provider; support MFA status…
dauglyon Jul 15, 2025
5991d79
move MFA status to token endpoint; remove mfaStatus endpoint; clean u…
dauglyon Jul 15, 2025
8197a61
fix build errors
dauglyon Jul 15, 2025
92bcfa3
fix test failures from MFA implementation
dauglyon Jul 16, 2025
7c6a378
Improve test coverage; improve error handling
dauglyon Jul 16, 2025
a4c2a7b
fix exception errors
dauglyon Jul 16, 2025
2540fd3
Update ORCID provider tests to use MockServer
dauglyon Jul 16, 2025
264be6c
remove duplicate tests
dauglyon Jul 16, 2025
f0b1612
fix TokenEndpointTest.getToken
dauglyon Jul 17, 2025
4588bcf
fix TokenEndpointTest.getToken
dauglyon Jul 17, 2025
2c1e074
Store MFA authentication status directly on tokens
dauglyon Jul 17, 2025
234e80f
update tests
dauglyon Jul 17, 2025
4d277a4
fix tests
dauglyon Jul 17, 2025
ac4a7fe
slightly better documentation
dauglyon Jul 17, 2025
219d328
update token field name
dauglyon Jul 17, 2025
f84cb2b
consolidate test logic
dauglyon Jul 17, 2025
00d3ca1
Change mfa value to enum, from boolean
dauglyon Jul 18, 2025
9a8415a
rename all uses MfaAuthenticated -> mfa
dauglyon Jul 18, 2025
f5ca03f
fix tests
dauglyon Jul 18, 2025
4b67b4e
rename all uses MfaAuthenticated -> mfa
dauglyon Jul 18, 2025
d45605e
rename jwt field for clarity
dauglyon Jul 18, 2025
7702956
fix tests, equalities
dauglyon Jul 18, 2025
fd763ef
throw errors for jwt parsing issues
dauglyon Jul 18, 2025
4ce5166
add check for null/missing AMR claim, fix tests
dauglyon Jul 18, 2025
53b2d65
Fix hashCode implementations
dauglyon Aug 12, 2025
bddba2c
Fix hascode expectations
dauglyon Aug 12, 2025
7074ff6
Fix test ordering expectation for identity list
dauglyon Aug 12, 2025
a9091bc
include both scopes, minor refactor
dauglyon Aug 28, 2025
c514203
Fix null fullname handling in ORCID OAuth flow
dauglyon Sep 8, 2025
540ff32
Fix null MFA field handling for backward compatibility
dauglyon Sep 8, 2025
0b2cdc1
Update ORCID provider tests for OpenID Connect requirements
dauglyon Sep 8, 2025
c919468
Add MFA status to all token responses
dauglyon Sep 9, 2025
5043d4b
Replace hashcode assertions with equaility assertions, backed by equa…
dauglyon Sep 22, 2025
fc9d0ee
Improve ORCID JWT error handling and test robustness
dauglyon Sep 22, 2025
d266fac
add missing test
dauglyon Oct 16, 2025
3857ac6
Update Authentication.java
dauglyon Oct 22, 2025
c8ff9d9
Remove MFA persistence from identity documents; make MFA session-only…
dauglyon Oct 21, 2025
c8b9689
Remove unnecessary fullName backwards compatibility in ORCID provider
dauglyon Oct 21, 2025
df6b15e
Refactor: code quality improvements from PR review
dauglyon Oct 25, 2025
c316f15
Refactor MfaStatus enum to follow TokenType/Role pattern
dauglyon Oct 25, 2025
facd37c
Narrow exception handling in parseAmrClaim
dauglyon Oct 25, 2025
747c4a4
Add backwards compatibility test for identity documents without MFA f…
dauglyon Oct 25, 2025
565a520
Support both member and non-member ORCID OAuth applications
dauglyon Oct 25, 2025
21ad651
Add MFA status support to testmode token endpoint
dauglyon Oct 25, 2025
2d45b4f
revert storage ID for compat with CI deploy
dauglyon Nov 3, 2025
a57a3ba
Update src/test/java/us/kbase/test/auth2/lib/AuthenticationTokenTest.…
dauglyon Nov 3, 2025
caf6894
Update src/main/java/us/kbase/auth2/lib/identity/RemoteIdentityDetail…
dauglyon Nov 3, 2025
8a8d346
Update src/main/java/us/kbase/auth2/lib/token/StoredToken.java
dauglyon Nov 3, 2025
451e9f6
Update src/main/java/us/kbase/auth2/lib/Authentication.java
dauglyon Nov 3, 2025
9a6d66b
Fix MFA enum to use consistent ID serialization and enforce non-null …
dauglyon Nov 3, 2025
9001081
Apply suggestion from @dauglyon
dauglyon Nov 3, 2025
e4bbbd6
Fix compilation errors and test failures from develop merge
dauglyon Nov 4, 2025
d219699
fix coverage
dauglyon Nov 7, 2025
6495675
Merge 0.7.3 release notes into 0.7.2 since 0.7.2 not yet released
dauglyon Nov 12, 2025
9d4c137
Fix MfaStatus enum to use consistent ID serialization and enforce non…
dauglyon Nov 12, 2025
010b964
Add Javadoc link tag for MfaStatus.UNKNOWN reference
dauglyon Nov 12, 2025
b55cc36
Clarify OrcID MFA documentation to specify missing JWT also throws error
dauglyon Nov 12, 2025
cc77ba4
Fix MfaStatus enum serialization and test expectations
dauglyon Nov 13, 2025
980961b
Add tests for getLoginURI with MFA enabled/disabled to achieve full b…
dauglyon Nov 13, 2025
a210941
Improve test clarity and coverage
dauglyon Nov 13, 2025
0e99fd7
revert mistaken test changes
dauglyon Nov 13, 2025
b27150d
Add test for invalid MFA status in test mode token creation
dauglyon Nov 13, 2025
d921886
Add TODO noting MFA field conflates persistent and transient data in …
dauglyon Nov 13, 2025
0c94f7d
add test for empty string JWT failure
dauglyon Nov 13, 2025
37f1807
add mfa to TempSessionData
dauglyon Nov 14, 2025
48c61e5
Add tests for MFA field in TemporarySessionData
dauglyon Nov 14, 2025
6814a61
Update MfaStatus.java
dauglyon Nov 14, 2025
2291fcc
Update RemoteIdentityTest.java
dauglyon Nov 14, 2025
8c59a94
Update Fields.java
dauglyon Nov 14, 2025
6398a65
Update Authentication.java
dauglyon Nov 14, 2025
959b647
address review comments
dauglyon Nov 14, 2025
1117f60
fix mongo storage to always update identities with MFA.Unknown
dauglyon Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Comment thread
MrCreosote marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.7.2

* Added MFA (Multi-Factor Authentication) status tracking for tokens
* The `/api/V2/token` endpoint now returns an `mfa` field
* ORCID provider updated to use OpenID Connect scope for MFA detection
* BACKWARDS INCOMPATIBILITY: Repeated or trailing underscores are
no longer allowed in usernames. Existing usernames are unaffected.
* Fixed a bug where usernames with underscores would not be matched in username searches if an
Expand Down
50 changes: 33 additions & 17 deletions src/main/java/us/kbase/auth2/lib/Authentication.java
Comment thread
MrCreosote marked this conversation as resolved.
Resolved

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.

There don't appear to be unit tests for any of the changes in this file

Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
import us.kbase.auth2.lib.exceptions.UserExistsException;
import us.kbase.auth2.lib.identity.IdentityProvider;
import us.kbase.auth2.lib.identity.RemoteIdentity;
import us.kbase.auth2.lib.identity.MfaStatus;
import us.kbase.auth2.lib.identity.RemoteIdentityID;
import us.kbase.auth2.lib.storage.AuthStorage;
import us.kbase.auth2.lib.storage.exceptions.AuthStorageException;
Expand Down Expand Up @@ -745,13 +746,22 @@ public void forceResetAllPasswords(final IncomingToken token)

private NewToken login(final UserName userName, final TokenCreationContext tokenCtx)
throws AuthStorageException {
return login(userName, tokenCtx, MfaStatus.UNKNOWN);
}
Comment on lines 747 to +750

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.

Suggested change
private NewToken login(final UserName userName, final TokenCreationContext tokenCtx)
throws AuthStorageException {
return login(userName, tokenCtx, MfaStatus.UNKNOWN);
}

Let's delete this method and make it explicit in the calling methods. Looking at the code, I think the createUser method needs to be updated to pass mfa status as well


private NewToken login(
final UserName userName,
final TokenCreationContext tokenCtx,
final MfaStatus mfa
) throws AuthStorageException {
final NewToken nt = new NewToken(StoredToken.getBuilder(
TokenType.LOGIN, randGen.randomUUID(), userName)
.withLifeTime(clock.instant(),
cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN))
.withContext(tokenCtx)
.build(),
randGen.getToken());
TokenType.LOGIN, randGen.randomUUID(), userName)
.withLifeTime(clock.instant(),
cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN))
.withContext(tokenCtx)
.withMfa(mfa)
.build(),
randGen.getToken());
storage.storeToken(nt.getStoredToken(), nt.getTokenHash());
setLastLogin(userName);
logInfo("Logged in user {} with token {}",
Expand Down Expand Up @@ -905,7 +915,8 @@ public NewToken createToken(
final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, au.getUserName())
.withLifeTime(clock.instant(), life)
.withContext(tokenCtx)
.withTokenName(tokenName).build(),
.withTokenName(tokenName)
.build(),
randGen.getToken());
storage.storeToken(nt.getStoredToken(), nt.getTokenHash());
logInfo("User {} created {} token {}", au.getUserName().getName(), tokenType, id);
Expand Down Expand Up @@ -1789,6 +1800,7 @@ public LoginToken login( // enough args here to start considering a builder
storage.deleteTemporarySessionData(token.getHashedToken());
final Set<RemoteIdentity> ris = idp.getIdentities(
authcode, tids.getPKCECodeVerifier().get(), false, environment);
final MfaStatus mfa = ris.iterator().next().getDetails().getMfa();
final LoginState lstate = getLoginState(ris, Instant.MIN);
final ProviderConfig pc = cfg.getAppConfig().getProviderConfig(idp.getProviderName());
final LoginToken loginToken;
Expand All @@ -1801,23 +1813,23 @@ public LoginToken login( // enough args here to start considering a builder
* this call may be the result of a redirect from a 3rd party
* provider. Any controllable error should be thrown when the process flow is back
* under the control of the primary auth UI.
*
*
* There's a tiny chance here that the user could be made an admin, be enabled, or
* non-admin login be enabled between this step and the getLoginState() step. The
* consequence is that they'll have to choose from one account in the next step,
* so who cares.
*/
if (!cfg.getAppConfig().isLoginAllowed() && !Role.isAdmin(user.getRoles())) {
loginToken = storeIdentitiesTemporarily(lstate);
loginToken = storeIdentitiesTemporarily(lstate, mfa);
} else if (user.isDisabled()) {
loginToken = storeIdentitiesTemporarily(lstate);
loginToken = storeIdentitiesTemporarily(lstate, mfa);
} else {
loginToken = new LoginToken(login(user.getUserName(), tokenCtx));
loginToken = new LoginToken(login(user.getUserName(), tokenCtx, mfa));
}
} else {
// store the identities so the user can create an account or choose from more than one
// account
loginToken = storeIdentitiesTemporarily(lstate);
loginToken = storeIdentitiesTemporarily(lstate, mfa);
}
return loginToken;
}
Expand All @@ -1831,13 +1843,13 @@ private void checkState(final TemporarySessionData tids, final String state)
}

// ignores expiration date of login state
private LoginToken storeIdentitiesTemporarily(final LoginState ls)
private LoginToken storeIdentitiesTemporarily(final LoginState ls, final MfaStatus mfa)
throws AuthStorageException {
final Set<RemoteIdentity> store = new HashSet<>(ls.getIdentities());
ls.getUsers().stream().forEach(u -> store.addAll(ls.getIdentities(u)));
final TemporarySessionData data = TemporarySessionData.create(
randGen.randomUUID(), clock.instant(), LOGIN_TOKEN_LIFETIME_MS)
.login(store);
.login(store, mfa);
final TemporaryToken tt = storeTemporarySessionData(data);
logInfo("Stored temporary token {} with {} login identities", tt.getId(), store.size());
return new LoginToken(tt);
Expand Down Expand Up @@ -2033,16 +2045,19 @@ public NewToken createUser(
public NewToken testModeCreateToken(
final UserName userName,
final TokenName tokenName,
final TokenType tokenType)
final TokenType tokenType,
final MfaStatus mfa)
throws TestModeException, AuthStorageException, NoSuchUserException {
ensureTestMode();
requireNonNull(userName, "userName");
requireNonNull(tokenType, "tokenType");
requireNonNull(mfa, "mfa");
storage.testModeGetUser(userName); // ensure user exists
final UUID id = randGen.randomUUID();
final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, userName)
.withLifeTime(clock.instant(), TEST_MODE_DATA_LIFETIME_MS)
.withNullableTokenName(tokenName)
.withMfa(mfa)
.build(),
randGen.getToken());
storage.testModeStoreToken(nt.getStoredToken(), nt.getTokenHash());
Expand Down Expand Up @@ -2339,7 +2354,8 @@ public NewToken login(
linked, u.get().getUserName().getName());
}
}
return login(u.get().getUserName(), tokenCtx);
final MfaStatus mfaStatus = ri.get().getDetails().getMfa();
return login(u.get().getUserName(), tokenCtx, mfaStatus);
Comment thread
dauglyon marked this conversation as resolved.
Resolved
}

private Optional<RemoteIdentity> getIdentity(
Expand Down Expand Up @@ -3136,7 +3152,7 @@ public <T extends ExternalConfig> AuthConfigSetWithUpdateTime<T> getConfig(

/** Returns the suggested cache time for tokens in milliseconds.
* @return the suggested cache time.
* @throws AuthStorageException if an error occurred accessing the storage system.
* @throws AuthStorageException if an error occurred accessing the storage system.
*/
public long getSuggestedTokenCacheTime() throws AuthStorageException {
return cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.EXT_CACHE);
Expand Down
40 changes: 31 additions & 9 deletions src/main/java/us/kbase/auth2/lib/TemporarySessionData.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.UUID;

import us.kbase.auth2.lib.exceptions.ErrorType;
import us.kbase.auth2.lib.identity.MfaStatus;
import us.kbase.auth2.lib.identity.RemoteIdentity;

/** Temporary session data that may include a set of temporary identities and / or an associated
Expand All @@ -33,6 +34,7 @@ public class TemporarySessionData {
private final String error;
private final ErrorType errorType;
private final UserName user;
private final MfaStatus mfa;

private TemporarySessionData(
final Operation op,
Expand All @@ -44,7 +46,8 @@ private TemporarySessionData(
final Set<RemoteIdentity> identities,
final UserName user,
final String error,
final ErrorType errorType) {
final ErrorType errorType,
final MfaStatus mfa) {
this.op = op;
this.id = id;
this.created = created;
Expand All @@ -55,6 +58,7 @@ private TemporarySessionData(
this.user = user;
this.error = error;
this.errorType = errorType;
this.mfa = requireNonNull(mfa, "mfa");
}

/** Get the operation this temporary session data supports.
Expand Down Expand Up @@ -137,10 +141,17 @@ public boolean hasError() {
return error != null;
}

/** Get the multi-factor authentication status.
* @return the MFA status.
*/
public MfaStatus getMfa() {
return mfa;
}

@Override
public int hashCode() {
return Objects.hash(created, error, errorType, expires, id, identities, oauth2State, op, pkceCodeVerifier,
user);
return Objects.hash(created, error, errorType, expires, id, identities, mfa, oauth2State, op,
pkceCodeVerifier, user);
}

@Override
Expand All @@ -158,7 +169,7 @@ public boolean equals(Object obj) {
return Objects.equals(created, other.created) && Objects.equals(error, other.error)
&& errorType == other.errorType && Objects.equals(expires, other.expires)
&& Objects.equals(id, other.id) && Objects.equals(identities, other.identities)
&& Objects.equals(oauth2State, other.oauth2State) && op == other.op
&& mfa == other.mfa && Objects.equals(oauth2State, other.oauth2State) && op == other.op
&& Objects.equals(pkceCodeVerifier, other.pkceCodeVerifier) && Objects.equals(user, other.user);
}

Expand Down Expand Up @@ -242,7 +253,7 @@ public TemporarySessionData error(final String error, final ErrorType errorType)
requireNonNull(errorType, "errorType");
return new TemporarySessionData(
Operation.ERROR, id, created, expires,
null, null, null, null, error, errorType);
null, null, null, null, error, errorType, MfaStatus.UNKNOWN);
}

/** Create temporary session data for the start of a login operation.
Expand All @@ -258,7 +269,7 @@ public TemporarySessionData login(
checkStringNoCheckedException(pkceCodeVerifier, "pkceCodeVerifier");
return new TemporarySessionData(
Operation.LOGINSTART, id, created, expires,
oauth2State, pkceCodeVerifier, null, null, null, null);
oauth2State, pkceCodeVerifier, null, null, null, null, MfaStatus.UNKNOWN);
}

/** Create temporary session data for a login operation where remote identities are
Expand All @@ -267,9 +278,20 @@ public TemporarySessionData login(
* @return the temporary session data.
*/
public TemporarySessionData login(final Set<RemoteIdentity> identities) {
return login(identities, MfaStatus.UNKNOWN);
}

/** Create temporary session data for a login operation where remote identities are
* involved.
* @param identities the remote identities involved in the login.
* @param mfa the multi-factor authentication status.
* @return the temporary session data.
*/
public TemporarySessionData login(final Set<RemoteIdentity> identities, final MfaStatus mfa) {
requireNonNull(mfa, "mfa");
return new TemporarySessionData(
Operation.LOGINIDENTS, id, created, expires,
null, null, checkIdents(identities), null, null, null);
null, null, checkIdents(identities), null, null, null, mfa);
}

private Set<RemoteIdentity> checkIdents(final Set<RemoteIdentity> identities) {
Expand Down Expand Up @@ -299,7 +321,7 @@ public TemporarySessionData link(
requireNonNull(userName, "userName");
return new TemporarySessionData(
Operation.LINKSTART, id, created, expires,
oauth2State, pkceCodeVerifier, null, userName, null, null);
oauth2State, pkceCodeVerifier, null, userName, null, null, MfaStatus.UNKNOWN);
}

/** Create temporary session data for a linking operation when remote identities are
Expand All @@ -314,7 +336,7 @@ public TemporarySessionData link(
requireNonNull(userName, "userName");
return new TemporarySessionData(
Operation.LINKIDENTS, id, created, expires,
null, null, checkIdents(identities), userName, null, null);
null, null, checkIdents(identities), userName, null, null, MfaStatus.UNKNOWN);
}
}
}
65 changes: 65 additions & 0 deletions src/main/java/us/kbase/auth2/lib/identity/MfaStatus.java
Comment thread
MrCreosote marked this conversation as resolved.
Resolved

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.

The admintoken, adminusertokens, tokencreate, and tokens mustache templates need to be updated to show MFA status, which probably means that their underlying APIs need to be updated as well

I was pulled off auth before I could add tests for the admin endpoints / templates so they can be tested manually

Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package us.kbase.auth2.lib.identity;
// TODO CODE move to lib/token
import java.util.HashMap;
import java.util.Map;

import com.fasterxml.jackson.annotation.JsonValue;

/** An enumeration representing the multi-factor authentication status of a user's login.
* @author dlyon@lbl.gov
*
*/
public enum MfaStatus {

/* first arg is ID, second arg is description. ID CANNOT change
* since that field is stored in the DB.
*/
/** User authenticated with MFA during token creation. */
USED ("Used", "MFA used"),
/** User explicitly chose not to use MFA when available. */
NOT_USED ("NotUsed", "MFA not used"),
/** MFA status unknown or not applicable to authentication method. */
UNKNOWN ("Unknown", "MFA status unknown");

private static final Map<String, MfaStatus> STATUS_MAP = new HashMap<>();
static {
for (final MfaStatus status: MfaStatus.values()) {
STATUS_MAP.put(status.getID(), status);
}
}

private final String id;
private final String description;

private MfaStatus(final String id, final String description) {
this.id = id;
this.description = description;
}

/** Get the ID of this MFA status.
* @return the ID.
*/
@JsonValue
public String getID() {
return id;
}

/** Get the description of this MFA status.
* @return the description.
*/
public String getDescription() {
return description;
}

/** Get an MFA status from its ID.
* @param id the ID of the MFA status.
* @return the MFA status.
* @throws IllegalArgumentException if there is no MFA status matching the ID.
*/
public static MfaStatus fromID(final String id) {
if (!STATUS_MAP.containsKey(id)) {
throw new IllegalArgumentException("Invalid MFA status: " + id);
}
return STATUS_MAP.get(id);
}
}
Loading
Loading