Search code examples
c#asp.net-coreoauth-2.0asp.net-core-mvc

How to Sign-in Users with OAuth Tokens


I am building an ASP.NET Core MVC web application that has two ways of authenticating; one way uses .AddOpenIdConnect() and is already working.

The second way is to retrieve OAuth tokens from a web service.

// MVC
public async Task<IActionResult> GetToken()
{
    var tokenResponse = await _tokenService.GetToken();

    // This header did not appear in the JwtBearerEvents
    string headerValue = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken).ToString();
    Request.Headers.Authorization = headerValue;

    return RedirectToAction("ShowToken", "Account");
}

// This AuthorizeAttribute causes JwtBearerEvents to fire
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public IActionResult ShowToken()
{    
    return View();
}

After retrieving tokens from a web service,

  1. How do I pass the the OAuth tokens to the authentication middleware? (I already tried setting the authorization header, but that didn't work.)
  2. How do I configure the authentication middleware to handle them?
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;

    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;

    options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; 
})
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.TokenHandlers.Add(new StrictTokenHandler());
    options.TokenValidationParameters = tokenValidationParameters;
    options.SaveToken = true;
    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            var p = context.Properties;
            // context.Token = <context does not contain token>                        
            return Task.CompletedTask;
        },
        OnChallenge = context =>
        {
            var p = context.Properties;
            // have token, but can't set Token
            return Task.CompletedTask;
        },
        OnTokenValidated = context =>
        {
            return Task.CompletedTask;
        },
    };
})
.AddCookie(options =>
{
    options.LoginPath = new PathString("/Account/Login");
})
.AddOpenIdConnect(options =>
{
    options.Authority = issuer;
    // the rest omitted for brevity
    // this is working
});

In the .AddJwtBearer() method above, the context MessageReceivedContext has a settable Token property, but I don't have it yet. The OnChallenge event has the token if I set it in AuthenticationProperties, but JwtBearerChallengeContext does not have a settable Token property.

This is the token information returned from the token service. How do I get this into the Authentication middleware?

Token Information Screenshot


Solution

  • Original Answer

    I have found a workaround of sorts, however I am not yet convinced that it is the best solution. (Update: I found a better solution, see update below.)

    The key seems to be to set the authorization header like this:

    string accessToken = tokenResponse.AccessToken;
    Request.Headers.Authorization = $"Bearer {accessToken}";
    

    ...right before calling AuthenticateAsync() like this:

    var result = await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
    

    AccountController.cs

    // MVC
    public async Task<IActionResult> GetToken()
    {
        var tokenResponse = await _tokenService.GetToken();
    
        // just for ShowToken - not required 
        TempData.Serialize("tokenInfo", new TokenViewModel()
        {
            TokenType = tokenResponse.TokenType,
            Expires = tokenResponse.Expires,
            AccessToken = tokenResponse.AccessToken,
            Scope = tokenResponse.Scope,
            RefreshToken = tokenResponse.RefreshToken,
            IdToken = tokenResponse.IdToken,
        });
    
        // this seems like a hack, but works - is there a better way?
        string accessToken = tokenResponse.AccessToken;
        Request.Headers.Authorization = $"Bearer {accessToken}";
        
        var result = await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
    
        if (result.Principal != null)
        {
            await HttpContext.SignInAsync(result.Principal);
            return RedirectToAction(nameof(AccountController.ShowToken), "Account");
        }
        else
        {
            return Challenge(
                new AuthenticationProperties { RedirectUri = Url.Action("Index", "Home") },
                OpenIdConnectDefaults.AuthenticationScheme);
        }
    }
    
    [HttpGet]
    [Authorize()]  // if not authorized, the fallback is the default, OIDC-Cookie auth
    public IActionResult ShowToken()
    {
        // Only for demonstration purposes
        var tokenViewModel = TempData.Deserialize<TokenViewModel>("tokenInfo");
        return View(tokenViewModel);
    }
    

    Program.cs

    services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; 
    }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            var httpClient = new HttpClient(new TestClientMessageHandler("starling-testclient", typeof(Program).Assembly.GetName().Version));
            var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
                issuer + "/.well-known/openid-configuration",
                new OpenIdConnectConfigurationRetriever(),
                new HttpDocumentRetriever(httpClient));
            var signingKeyProvider = new DiscoveryDocumentSigningKeyProvider(configurationManager);
            var tokenValidationParameters = new TokenValidationParameters()
            {
                ValidateIssuer = true,
                ValidIssuer = issuer,  // configured above
    
                ValidateAudience = true,
                ValidAudience = audience,  // configured above
    
                RequireSignedTokens = true,
                ValidateIssuerSigningKey = true,
                IssuerSigningKeyResolver = (token, securityToken, keyId, validationParameters) =>
                {
                    var signingKeys = signingKeyProvider.GetSigningKeysAsync().GetAwaiter().GetResult();
                    return signingKeys.Where(x => x.KeyId == keyId);
                },
                NameClaimType = JwtRegisteredClaimNames.Name,
    
                RequireExpirationTime = true,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.FromMinutes(2.0)
            };
            options.TokenHandlers.Add(new StrictTokenHandler());  // custom class, not required
            options.TokenValidationParameters = tokenValidationParameters;
            options.SaveToken = true;
            options.Events = new JwtBearerEvents
            {
                OnTokenValidated = context =>
                {
                    var token = context.SecurityToken;
                    Debug.Assert(token != null);
                    
                    var principal = context.Principal;                        
                    Debug.Assert(principal?.Identity?.IsAuthenticated == true);   
                    
                    return Task.CompletedTask;
                }, 
            };
        })
        .AddCookie(options =>
        {
            options.LoginPath = new PathString("/Account/Login");
        })
        .AddOpenIdConnect(options =>
        {
            options.Authority = issuer;
            options.SaveTokens = true;
            
            // details omitted for brevity
        });
    

    UPDATE - Solution

    After retrieving the token, simply read it and create a new authenticated ClaimsPrincipal like this:

    var handler = new JwtSecurityTokenHandler();
    var jwtSecurityToken = handler.ReadJwtToken(tokenResponse.AccessToken);
    var identity = new ClaimsIdentity(jwtSecurityToken.Claims, "auth-type-token");
    var principal = new ClaimsPrincipal(identity);
    

    Note: It's important to set the AuthenticationType parameter when creating the ClaimsIdentity. Otherwise, IsAuthenticated will be false.

    After creating ClaimsPrincipal, call SignInAsync():

    await HttpContext.SignInAsync(principal);
    

    At this point, a standard ASP.NET auth cookie is used.