-
Notifications
You must be signed in to change notification settings - Fork 645
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
[2FA] Add AAD login support for NuGet Gallery #5257
Conversation
{ | ||
foreach (var authenticator in Authenticators.Values) | ||
var userInfo = author.GetAuthInformation(externalIdentity); | ||
var emailSuffix = userInfo.Email == null ? String.Empty : (" <" + userInfo.Email + ">"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very much a nit and pretty sure the gallery code base is inconsistent, but should we prefer string
over String
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I just copied this over from previous place. I usually use string
. Will fix it.
- done
public AuthInformation() { } | ||
|
||
public AuthInformation(string identifier, string name, string email, string authenticationType, string tenantId = null) | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need argument null checks here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, validation can be done in the service where needed.
|
||
public string AuthenticationType { get; private set; } | ||
|
||
public AuthInformation() { } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty constructor and only read-only property access? Smells weird..
- removed
/// <returns><see cref="AuthInformation"/></returns> | ||
public virtual AuthInformation GetAuthInformation(ClaimsIdentity claimsIdentity) | ||
{ | ||
return new AuthInformation(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any use in returning this new AuthInformation()
? Should this become an abstract
method instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, its better.
@@ -474,12 +473,21 @@ public virtual ActionResult Challenge(string providerName, string redirectUrl) | |||
public virtual CredentialViewModel DescribeCredential(Credential credential) | |||
{ | |||
var kind = GetCredentialKind(credential.Type); | |||
Authenticator auther = null; | |||
Authenticator author = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think auther
was intentional... as in, the one doing the "auth".
Agree it's a bad name, but maybe authenticator
is a better one?
- done
/// </summary> | ||
/// <param name="claimsIdentity">The claims identity returned by the identity</param> | ||
/// <returns>Returns true if this provider is the author for the identity, false otherwise</returns> | ||
public virtual bool IsAuthorForIdentity(ClaimsIdentity claimsIdentity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Author
and Auther
are confusing me.
Can we rename auther
to authenticator
, and maybe change this signature to bool IsProviderForIdentity(ClaimsIdentity)
?
- done
{ | ||
if (string.Equals(issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase)) | ||
// If the issuer of the claims identity is same as that of the authentication type then this is the author. | ||
Claim firstClaim = claimsIdentity?.Claims?.FirstOrDefault(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: use var
- done
} | ||
|
||
authenticationType = null; | ||
return false; | ||
return string.Equals(firstClaim.Issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If BaseConfig.AuthenticationType
were guaranteed to be non-null, we could remove the block above and just do:
string.Equals(firstClaim?.Issuer, BaseConfig.AuthenticationType, StringComparison.OrdinalIgnoreCase);
I don't think AuthenticationConfiguration enforces this though. :-(
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right, it is not enforced. I would rather have the check to prevent unseen issues later.
} | ||
|
||
return base.TryMapIssuerToAuthenticationType(issuer, out authenticationType); | ||
return MSAIdentityExtractor.GetAuthInformation(claimsIdentity, DefaultAuthenticationType); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MSAIdentityExtractor is for both MSA and AAD?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, for the previous schema MSA and AAD used the same claims to get values. This is an interim thing until we get rid of old providers.
} | ||
|
||
public static readonly string DefaultAuthenticationType = "CommonAuth"; | ||
public static readonly string PersonalMSATenant = "9188040d-6c67-4c5b-b112-36a304b66dad"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this is personal (non-tenant) token id? Does that imply MSA, or can still be AAD?
Just looking at:
https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the tenant for Personal MSA accounts, under the v2 protocol it is returned as the tenant id for Personal MSA accounts, same as the previous external.MicrosoftAccount
credentials validated using the MSA flow.
if (siteRoot.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) | ||
{ | ||
siteRoot = siteRoot.Replace("http://", "https://"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this necessary - don't we already have redirect configuration for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This value is passed to the Azure AD to redirect with the claims after authentication. This is added as a query string and validated on the AD side before redirection, it needs to be https
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe clarify in comment why this redirect is necessary for AAD, so nobody mistakenly removes it?
// where as the existing Microsoft account identifiers are 16 character wide. | ||
// For e.g old format: 0ae45d63e22e4a60, newer format: 00000000-0000-0000-0AE4-5D63-E22E4A60 | ||
// We need to format the values into the older format for backwards compatibility | ||
identifier = idClaim.Value.Replace("-", "").Substring(16).ToLowerInvariant(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you know why they left pad with zeros? Just wondering if we'll ever need those digits... would it be useful to have 2 versions of the Id claim, for 16- and 32- characters?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I checked with Identity
folks and it is safe to use the 16 character string for MSA accounts.
var emailClaim = claimsIdentity.FindFirst(ClaimTypes.Email); | ||
return new AuthInformation(identifierClaim.Value, nameClaim.Value, emailClaim?.Value, authType, tenantId: null); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there anything MSA specific in here? Could this just be put into a generic ClaimsExtensions
class?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing specific for MSA, renamed it to ClaimsExtensions
, this will be removed eventually when we remove older providers.
name = userInfo.Name; | ||
email = userInfo.Email; | ||
} | ||
catch (Exception) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a more specific Exception type we expect to see? What's the scenario?
Should we add trace log?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Before this change, when we found a user trying to register a new account directly with MSA, we would show email
we got from the auth and allow the user to edit. Going forward, we will not allow this to happen, however, for backwards compatibility(more so especially for tests) we get one or the other claim. I haven't yet modified those tests as we need to maintain backwards compatibility. Hence, for now we will swallow the exceptions. In future we will surface the error to the view model and show appropriate error to the user. No point in adding trace here yet.
|
||
namespace NuGetGallery.Authentication.Providers | ||
{ | ||
public class AuthInformation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use full words (AuthenticationInformation)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renamed to IdentityInformation
which seems more accurate.
{ | ||
author = authenticator; | ||
break; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can retwrite this using FirstOrDefault
using NuGetGallery.Configuration; | ||
using Owin; | ||
|
||
namespace NuGetGallery.Authentication.Providers.CommonAuth |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use Aythentication and not Auth everywhere
var nameClaim = claimsIdentity.FindFirst(V2Claims.Name); | ||
if (nameClaim == null) | ||
{ | ||
throw new ArgumentException($"External Authentication is missing required claim: '{V2Claims.Name}'"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we require Name claim? Why would it be missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed the name claim null check, since it is not a necessity.
var identifierClaim = claimsIdentity.FindFirst(ClaimTypes.NameIdentifier); | ||
if (identifierClaim == null) | ||
{ | ||
throw new ArgumentException("External Authentication is missing required claim: " + ClaimTypes.NameIdentifier); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the 2 exception strings are formatted differently
- fixed.
Authenticators.TryGetValue(providerName, out auther); | ||
if (string.IsNullOrEmpty(credential.TenantId)) | ||
{ | ||
string providerName = credential.Type.Split('.')[1]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will the providerName always be the second ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. external.MicrosoftAccount
external.AzureActiveDiretory
return string.Equals(issuer.Value, expectedIssuer, StringComparison.OrdinalIgnoreCase); | ||
} | ||
|
||
public override AuthInformation GetAuthInformation(ClaimsIdentity claimsIdentity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
May be combine this and above method as TryGet
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked into this, and I think its better to throw the exception here, given that we want to disallow missing emails going forward. In subsequent PRs I will clean it up to throw a well defined exception that we can surface to the user as missing email.
|
||
if (kind == CredentialKind.External) | ||
{ | ||
string providerName = credential.Type.Split('.')[1]; | ||
Authenticators.TryGetValue(providerName, out auther); | ||
if (string.IsNullOrEmpty(credential.TenantId)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, we'll switch over to the new APIs (OpenIdConnect) once the SQL is run to populate tenantId for existing credentials?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, and after we enable the config values for new provider.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great!
{ | ||
authenticator = Authenticators | ||
.Values | ||
.First(provider => provider.Name.Equals(AzureActiveDirectoryV2Authenticator.DefaultAuthenticationType, StringComparison.OrdinalIgnoreCase)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not use TryGetValue
here or FirstOrDefault
like you do for identity lookup. Just wondering about consistency.
- Good idea to use
FirstOrDefault
var nameClaim = result.Identity.FindFirst(ClaimTypes.Name); | ||
if (nameClaim == null) | ||
var externalIdentity = result.Identity; | ||
Authenticator author = Authenticators |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would rename author
=> authenticator
@@ -687,13 +682,13 @@ private string FormatCredentialType(string credentialType) | |||
|
|||
private string FormatExternalCredentialType(string externalType) | |||
{ | |||
Authenticator auther; | |||
if (!Authenticators.TryGetValue(externalType, out auther)) | |||
Authenticator author; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would rename author
=> authenticator
/// </summary> | ||
/// <param name="claimsIdentity">The claims identity returned by the identity</param> | ||
/// <returns><see cref="IdentityInformation"/></returns> | ||
public virtual IdentityInformation GetIdentityInformation(ClaimsIdentity claimsIdentity) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not leave this abstract? Do any children actually use this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, there are other children where we need default value.
if (siteRoot.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) | ||
{ | ||
siteRoot = siteRoot.Replace("http://", "https://"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe clarify in comment why this redirect is necessary for AAD, so nobody mistakenly removes it?
{ | ||
base.ApplyToOwinSecurityOptions(options); | ||
|
||
var opts = options as OpenIdConnectAuthenticationOptions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: would rename to reflect more specific cast, like openIdOptions
[Fact] | ||
public void GetUIReturnsCorrectValues() | ||
{ | ||
//Arrange |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: space after //
This PR adds AAD support to NuGet gallery. The new login will work just like the personal MSA. You can login with your AAD account and link it to an existing nuget.org account or create a new account. This is configured as a new provider, so that we can transition away from previous way of logging in.
I will add new configuration values in deployment PR. This PR uses
Microsoft.Owin.Security.OpenIdConnect
middleware which can now login with a common endpoint that facilitates single sign on flow for Personal/Work microsoft accounts.Also, the code takes care of maintaining backwards compatibility with existing MSA accounts and translates the account information into older format. Also added tests for the new provider and some missing coverage in
AuthenticationService
.