Skip to content

[ CDM-243 ] [ CDM-245 ] Orcid Provider MFA Support & Token response mfaAuthenticated key #471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e78616e
Add openid role and mfa parsing to Orcid provider; support MFA status…
dauglyon Jul 15, 2025
984fdb2
move MFA status to token endpoint; remove mfaStatus endpoint; clean u…
dauglyon Jul 15, 2025
e23c5dd
fix build errors
dauglyon Jul 15, 2025
5255abf
fix test failures from MFA implementation
dauglyon Jul 16, 2025
99f0573
reorder expected html for test
dauglyon Jul 16, 2025
748c8a2
Improve test coverage; improve error handling
dauglyon Jul 16, 2025
589d52a
fix exception errors
dauglyon Jul 16, 2025
099ca8d
Update ORCID provider tests to use MockServer
dauglyon Jul 16, 2025
4cbf2ec
remove duplicate tests
dauglyon Jul 16, 2025
ef094fd
fix TokenEndpointTest.getToken
dauglyon Jul 17, 2025
ac606a5
fix TokenEndpointTest.getToken
dauglyon Jul 17, 2025
9dfc0a7
Store MFA authentication status directly on tokens
dauglyon Jul 17, 2025
53ed176
update tests
dauglyon Jul 17, 2025
ae74a21
fix tests
dauglyon Jul 17, 2025
935f193
slightly better documentation
dauglyon Jul 17, 2025
7bbf759
update token field name
dauglyon Jul 17, 2025
3d9e93e
consolidate test logic
dauglyon Jul 17, 2025
8d85ad9
Change mfa value to enum, from boolean
dauglyon Jul 18, 2025
2db7814
rename all uses MfaAuthenticated -> mfa
dauglyon Jul 18, 2025
328704c
fix tests
dauglyon Jul 18, 2025
90ce1af
rename all uses MfaAuthenticated -> mfa
dauglyon Jul 18, 2025
58a2aec
rename jwt field for clarity
dauglyon Jul 18, 2025
c766704
fix tests, equalities
dauglyon Jul 18, 2025
37d2ef6
throw errors for jwt parsing issues
dauglyon Jul 18, 2025
62d6066
add check for null/missing AMR claim, fix tests
dauglyon Jul 18, 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
6 changes: 6 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Authentication Service MKII release notes

## 0.7.3

* 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

## 0.7.2

* Fixed a bug where usernames with underscores would not be matched in username searches if an
Expand Down
26 changes: 18 additions & 8 deletions src/main/java/us/kbase/auth2/lib/Authentication.java
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,19 @@ public void forceResetAllPasswords(final IncomingToken token)

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

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 +912,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 @@ -2339,7 +2347,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);
}

private Optional<RemoteIdentity> getIdentity(
Expand Down Expand Up @@ -3142,6 +3151,7 @@ public long getSuggestedTokenCacheTime() throws AuthStorageException {
return cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.EXT_CACHE);
}


/** Get the external configuration without providing any credentials.
*
* This method should not be exposed in a public API.
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/us/kbase/auth2/lib/identity/MfaStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package us.kbase.auth2.lib.identity;

/** An enumeration representing the multi-factor authentication status of a user's login. */
public enum MfaStatus {
/** User authenticated with MFA during token creation. */
USED,
/** User explicitly chose not to use MFA when available. */
NOT_USED,
/** MFA status unknown or not applicable to authentication method. */
UNKNOWN;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class RemoteIdentityDetails {
private final String username;
private final String fullname;
private final String email;
private final MfaStatus mfa;

/** Create a new set of details.
* @param username the user name of the identity.
Expand All @@ -20,6 +21,20 @@ public RemoteIdentityDetails(
final String username,
final String fullname,
final String email) {
this(username, fullname, email, MfaStatus.UNKNOWN);
}

/** Create a new set of details.
* @param username the user name of the identity.
* @param fullname the full name of the identity. Null is acceptable.
* @param email the email address of the identity. Null is acceptable.
* @param mfa the multi-factor authentication status.
*/
public RemoteIdentityDetails(
final String username,
final String fullname,
final String email,
final MfaStatus mfa) {
super();
if (username == null || username.trim().isEmpty()) {
throw new IllegalArgumentException(
Expand All @@ -36,6 +51,7 @@ public RemoteIdentityDetails(
} else {
this.email = email.trim();
}
this.mfa = mfa;
}

/** Get the user name for the identity.
Expand All @@ -57,13 +73,21 @@ public String getFullname() {
public String getEmail() {
return email;
}

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

@Override
public int hashCode() {
final int prime = 31;
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.hashCode());
result = prime * result + ((username == null) ? 0 : username.hashCode());
return result;
}
Expand Down Expand Up @@ -94,6 +118,13 @@ public boolean equals(Object obj) {
} else if (!fullname.equals(other.fullname)) {
return false;
}
if (mfa == null) {
if (other.mfa != null) {
return false;
}
} else if (!mfa.equals(other.mfa)) {
return false;
}
if (username == null) {
if (other.username != null) {
return false;
Expand All @@ -113,6 +144,8 @@ public String toString() {
builder.append(fullname);
builder.append(", email=");
builder.append(email);
builder.append(", mfa=");
builder.append(mfa);
builder.append("]");
return builder.toString();
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ public class Fields {
public static final String IDENTITIES_NAME = "fullname";
/** The email address of the identity. */
public static final String IDENTITIES_EMAIL = "email";
/** Whether the identity was authenticated with multi-factor authentication. */
public static final String IDENTITIES_MFA = "mfa";

/* **************
* token fields
Expand Down Expand Up @@ -135,6 +137,8 @@ public class Fields {
public static final String TOKEN_CUSTOM_KEY = "k";
/** A value for a custom context key / value pair. */
public static final String TOKEN_CUSTOM_VALUE = "v";
/** Whether the token was created with multi-factor authentication. */
public static final String TOKEN_MFA = "mfa";

/* ************************
* temporary session data fields
Expand Down
14 changes: 10 additions & 4 deletions src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
import us.kbase.auth2.lib.exceptions.UserExistsException;
import us.kbase.auth2.lib.identity.RemoteIdentity;
import us.kbase.auth2.lib.identity.RemoteIdentityDetails;
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 @@ -871,7 +872,8 @@ private void storeToken(final String collection, final StoredToken token, final
.append(Fields.TOKEN_DEVICE, ctx.getDevice().orElse(null))
.append(Fields.TOKEN_IP, ctx.getIpAddress().isPresent() ?
ctx.getIpAddress().get().getHostAddress() : null)
.append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext()));
.append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext()))
.append(Fields.TOKEN_MFA, token.getMfa().name());
try {
db.getCollection(collection).insertOne(td);
} catch (MongoWriteException mwe) {
Expand Down Expand Up @@ -969,6 +971,7 @@ private StoredToken getToken(final Document t) throws AuthStorageException {
t.getDate(Fields.TOKEN_EXPIRY).toInstant())
.withNullableTokenName(getTokenName(t.getString(Fields.TOKEN_NAME)))
.withContext(toTokenCreationContext(t))
.withMfa(MfaStatus.valueOf(t.getString(Fields.TOKEN_MFA)))
.build();
}

Expand Down Expand Up @@ -1596,7 +1599,8 @@ private void updateIdentity(final RemoteIdentity remoteID)
final Document update = new Document("$set",
new Document(pre + Fields.IDENTITIES_USER, rid.getUsername())
.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()));
try {
// id might have been unlinked, so we just assume
// the update worked. If it was just unlinked we don't care.
Expand Down Expand Up @@ -1729,7 +1733,8 @@ private Document toDocument(final RemoteIdentity id) {
.append(Fields.IDENTITIES_PROV_ID, id.getRemoteID().getProviderIdentityId())
.append(Fields.IDENTITIES_USER, rid.getUsername())
.append(Fields.IDENTITIES_NAME, rid.getFullname())
.append(Fields.IDENTITIES_EMAIL, rid.getEmail());
.append(Fields.IDENTITIES_EMAIL, rid.getEmail())
.append(Fields.IDENTITIES_MFA, rid.getMfa().name());
}

@Override
Expand Down Expand Up @@ -1789,7 +1794,8 @@ private Set<RemoteIdentity> toIdentities(final List<Document> ids) {
final RemoteIdentityDetails det = new RemoteIdentityDetails(
i.getString(Fields.IDENTITIES_USER),
i.getString(Fields.IDENTITIES_NAME),
i.getString(Fields.IDENTITIES_EMAIL));
i.getString(Fields.IDENTITIES_EMAIL),
MfaStatus.valueOf(i.getString(Fields.IDENTITIES_MFA)));
ret.add(new RemoteIdentity(rid, det));
}
return ret;
Expand Down
36 changes: 34 additions & 2 deletions src/main/java/us/kbase/auth2/lib/token/StoredToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import us.kbase.auth2.lib.TokenCreationContext;
import us.kbase.auth2.lib.UserName;
import us.kbase.auth2.lib.identity.MfaStatus;

/** A token associated with a user stored in the authentication storage system.
*
Expand All @@ -23,6 +24,7 @@ public class StoredToken {
private final UserName userName;
private final Instant creationDate;
private final Instant expirationDate;
private final MfaStatus mfa;

private StoredToken(
final UUID id,
Expand All @@ -31,7 +33,8 @@ private StoredToken(
final UserName userName,
final TokenCreationContext context,
final Instant creationDate,
final Instant expirationDate) {
final Instant expirationDate,
final MfaStatus mfa) {
// this stuff is here just in case naughty users use casting to skip a builder step
requireNonNull(creationDate, "created");
// no way to test this one
Expand All @@ -43,6 +46,7 @@ private StoredToken(
this.expirationDate = expirationDate;
this.creationDate = creationDate;
this.id = id;
this.mfa = mfa;
}

/** Get the type of the token.
Expand Down Expand Up @@ -93,6 +97,13 @@ public Instant getCreationDate() {
public Instant getExpirationDate() {
return expirationDate;
}

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

@Override
public int hashCode() {
Expand All @@ -105,6 +116,7 @@ public int hashCode() {
result = prime * result + ((tokenName == null) ? 0 : tokenName.hashCode());
result = prime * result + ((type == null) ? 0 : type.hashCode());
result = prime * result + ((userName == null) ? 0 : userName.hashCode());
result = prime * result + ((mfa == null) ? 0 : mfa.hashCode());
return result;
}

Expand Down Expand Up @@ -165,6 +177,13 @@ public boolean equals(Object obj) {
} else if (!userName.equals(other.userName)) {
return false;
}
if (mfa == null) {
if (other.mfa != null) {
return false;
}
} else if (!mfa.equals(other.mfa)) {
return false;
}
return true;
}

Expand Down Expand Up @@ -224,6 +243,12 @@ public interface OptionalsStep {
*/
OptionalsStep withContext(TokenCreationContext context);

/** Specify whether the token was created with multi-factor authentication.
* @param mfa the MFA status.
* @return this builder.
*/
OptionalsStep withMfa(MfaStatus mfa);

/** Build the token.
* @return a new StoredToken.
*/
Expand All @@ -239,6 +264,7 @@ private static class Builder implements LifeStep, OptionalsStep {
private final UserName userName;
private Instant creationDate;
private Instant expirationDate;
private MfaStatus mfa = MfaStatus.UNKNOWN;

private Builder(final TokenType type, final UUID id, final UserName userName) {
requireNonNull(type, "type");
Expand Down Expand Up @@ -269,10 +295,16 @@ public OptionalsStep withContext(final TokenCreationContext context) {
return this;
}

@Override
public OptionalsStep withMfa(final MfaStatus mfa) {
this.mfa = mfa;
return this;
}

@Override
public StoredToken build() {
return new StoredToken(id, type, tokenName, userName, context,
creationDate, expirationDate);
creationDate, expirationDate, mfa);
}

@Override
Expand Down
Loading
Loading