File: TwitterHandler.cs
Web Access
Project: src\src\Security\Authentication\Twitter\src\Microsoft.AspNetCore.Authentication.Twitter.csproj (Microsoft.AspNetCore.Authentication.Twitter)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
 
namespace Microsoft.AspNetCore.Authentication.Twitter;
 
/// <summary>
/// Authentication handler for Twitter's OAuth based authentication.
/// </summary>
public partial class TwitterHandler : RemoteAuthenticationHandler<TwitterOptions>
{
    private HttpClient Backchannel => Options.Backchannel;
 
    /// <summary>
    /// The handler calls methods on the events which give the application control at certain points where processing is occurring.
    /// If it is not provided a default instance is supplied which does nothing when the methods are called.
    /// </summary>
    protected new TwitterEvents Events
    {
        get { return (TwitterEvents)base.Events; }
        set { base.Events = value; }
    }
 
    /// <summary>
    /// Initializes a new instance of <see cref="TwitterHandler"/>.
    /// </summary>
    /// <inheritdoc />
    [Obsolete("ISystemClock is obsolete, use TimeProvider on AuthenticationSchemeOptions instead.")]
    public TwitterHandler(IOptionsMonitor<TwitterOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    { }
 
    /// <summary>
    /// Initializes a new instance of <see cref="TwitterHandler"/>.
    /// </summary>
    /// <inheritdoc />
    public TwitterHandler(IOptionsMonitor<TwitterOptions> options, ILoggerFactory logger, UrlEncoder encoder)
        : base(options, logger, encoder)
    { }
 
    /// <inheritdoc />
    protected override Task<object> CreateEventsAsync() => Task.FromResult<object>(new TwitterEvents());
 
    /// <inheritdoc />
    protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
    {
        var query = Request.Query;
        var protectedRequestToken = Request.Cookies[Options.StateCookie.Name!];
 
        var requestToken = Options.StateDataFormat.Unprotect(protectedRequestToken);
 
        if (requestToken == null)
        {
            return HandleRequestResults.InvalidStateCookie;
        }
 
        var properties = requestToken.Properties;
 
        var denied = query["denied"];
        if (!StringValues.IsNullOrEmpty(denied))
        {
            // Note: denied errors are special protocol errors indicating the user didn't
            // approve the authorization demand requested by the remote authorization server.
            // Since it's a frequent scenario (that is not caused by incorrect configuration),
            // denied errors are handled differently using HandleAccessDeniedErrorAsync().
            var result = await HandleAccessDeniedErrorAsync(properties);
            return !result.None ? result
                : HandleRequestResult.Fail("Access was denied by the resource owner or by the remote server.", properties);
        }
 
        var returnedToken = query["oauth_token"];
        if (StringValues.IsNullOrEmpty(returnedToken))
        {
            return HandleRequestResult.Fail("Missing oauth_token", properties);
        }
 
        if (!string.Equals(returnedToken, requestToken.Token, StringComparison.Ordinal))
        {
            return HandleRequestResult.Fail("Unmatched token", properties);
        }
 
        var oauthVerifier = query["oauth_verifier"];
        if (StringValues.IsNullOrEmpty(oauthVerifier))
        {
            return HandleRequestResult.Fail("Missing or blank oauth_verifier", properties);
        }
 
        var cookieOptions = Options.StateCookie.Build(Context, TimeProvider.GetUtcNow());
 
        Response.Cookies.Delete(Options.StateCookie.Name!, cookieOptions);
 
        var accessToken = await ObtainAccessTokenAsync(requestToken, oauthVerifier.ToString());
 
        var identity = new ClaimsIdentity(new[]
        {
                new Claim(ClaimTypes.NameIdentifier, accessToken.UserId, ClaimValueTypes.String, ClaimsIssuer),
                new Claim(ClaimTypes.Name, accessToken.ScreenName, ClaimValueTypes.String, ClaimsIssuer),
                new Claim("urn:twitter:userid", accessToken.UserId, ClaimValueTypes.String, ClaimsIssuer),
                new Claim("urn:twitter:screenname", accessToken.ScreenName, ClaimValueTypes.String, ClaimsIssuer)
            },
        ClaimsIssuer);
 
        JsonDocument user;
        if (Options.RetrieveUserDetails)
        {
            user = await RetrieveUserDetailsAsync(accessToken);
        }
        else
        {
            user = JsonDocument.Parse("{}"{}");
        }
 
        using (user)
        {
            if (Options.SaveTokens)
            {
                properties.StoreTokens(new[] {
                    new AuthenticationToken { Name = "access_token", Value = accessToken.Token },
                    new AuthenticationToken { Name = "access_token_secret", Value = accessToken.TokenSecret }
                    });
            }
 
            var ticket = await CreateTicketAsync(identity, properties, accessToken, user.RootElement);
            return HandleRequestResult.Success(ticket);
        }
    }
 
    /// <summary>
    /// Creates an <see cref="AuthenticationTicket"/> from the specified <paramref name="token"/>.
    /// </summary>
    /// <param name="identity">The <see cref="ClaimsIdentity"/>.</param>
    /// <param name="properties">The <see cref="AuthenticationProperties"/>.</param>
    /// <param name="token">The <see cref="AccessToken"/>.</param>
    /// <param name="user">The <see cref="JsonElement"/> for the user.</param>
    /// <returns>The <see cref="AuthenticationTicket"/>.</returns>
    protected virtual async Task<AuthenticationTicket> CreateTicketAsync(
        ClaimsIdentity identity, AuthenticationProperties properties, AccessToken token, JsonElement user)
    {
        foreach (var action in Options.ClaimActions)
        {
            action.Run(user, identity, ClaimsIssuer);
        }
 
        var context = new TwitterCreatingTicketContext(Context, Scheme, Options, new ClaimsPrincipal(identity), properties, token.UserId, token.ScreenName, token.Token, token.TokenSecret, user);
        await Events.CreatingTicket(context);
 
        return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
    }
 
    /// <inheritdoc />
    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        if (string.IsNullOrEmpty(properties.RedirectUri))
        {
            properties.RedirectUri = OriginalPathBase + OriginalPath + Request.QueryString;
        }
 
        // If CallbackConfirmed is false, this will throw
        var requestToken = await ObtainRequestTokenAsync(BuildRedirectUri(Options.CallbackPath), properties);
        var twitterAuthenticationEndpoint = TwitterDefaults.AuthenticationEndpoint + requestToken.Token;
 
        var cookieOptions = Options.StateCookie.Build(Context, TimeProvider.GetUtcNow());
 
        Response.Cookies.Append(Options.StateCookie.Name!, Options.StateDataFormat.Protect(requestToken), cookieOptions);
 
        var redirectContext = new RedirectContext<TwitterOptions>(Context, Scheme, Options, properties, twitterAuthenticationEndpoint);
        await Events.RedirectToAuthorizationEndpoint(redirectContext);
    }
 
    private async Task<HttpResponseMessage> ExecuteRequestAsync(string url, HttpMethod httpMethod, RequestToken? accessToken = null, Dictionary<string, string>? extraOAuthPairs = null, Dictionary<string, string>? queryParameters = null, Dictionary<string, string>? formData = null)
    {
        var authorizationParts = new SortedDictionary<string, string>(extraOAuthPairs ?? new Dictionary<string, string>())
            {
                { "oauth_consumer_key", Options.ConsumerKey! },
                { "oauth_nonce", Guid.NewGuid().ToString("N") },
                { "oauth_signature_method", "HMAC-SHA1" },
                { "oauth_timestamp", GenerateTimeStamp() },
                { "oauth_version", "1.0" }
            };
 
        if (accessToken != null)
        {
            authorizationParts.Add("oauth_token", accessToken.Token);
        }
 
        var signatureParts = new SortedDictionary<string, string>(authorizationParts);
        if (queryParameters != null)
        {
            foreach (var queryParameter in queryParameters)
            {
                signatureParts.Add(queryParameter.Key, queryParameter.Value);
            }
        }
        if (formData != null)
        {
            foreach (var formItem in formData)
            {
                signatureParts.Add(formItem.Key, formItem.Value);
            }
        }
 
        var stringBuilder = new StringBuilder();
        foreach (var signaturePart in signatureParts)
        {
            stringBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", Uri.EscapeDataString(signaturePart.Key), Uri.EscapeDataString(signaturePart.Value));
        }
        stringBuilder.Length--;
        var parameterString = stringBuilder.ToString();
        stringBuilder.Clear();
 
        stringBuilder.Append(httpMethod.Method);
        stringBuilder.Append('&');
        stringBuilder.Append(Uri.EscapeDataString(url));
        stringBuilder.Append('&');
        stringBuilder.Append(Uri.EscapeDataString(parameterString));
 
        var signature = ComputeSignature(Options.ConsumerSecret!, accessToken?.TokenSecret, stringBuilder.ToString());
        stringBuilder.Clear();
        authorizationParts.Add("oauth_signature", signature);
 
        var queryString = "";
        if (queryParameters != null)
        {
            stringBuilder.Append('?');
            foreach (var queryParam in queryParameters)
            {
                stringBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", queryParam.Key, queryParam.Value);
            }
            stringBuilder.Length--;
            queryString = stringBuilder.ToString();
            stringBuilder.Clear();
        }
 
        stringBuilder.Append("OAuth ");
        foreach (var authorizationPart in authorizationParts)
        {
            stringBuilder.AppendFormat(CultureInfo.InvariantCulture, "{0}=\"{1}\",", authorizationPart.Key, Uri.EscapeDataString(authorizationPart.Value));
        }
        stringBuilder.Length--;
 
        var request = new HttpRequestMessage(httpMethod, url + queryString);
        request.Headers.Add("Authorization", stringBuilder.ToString());
        stringBuilder.Clear();
 
        // This header is so that the error response is also JSON - without it the success response is already JSON
        request.Headers.Add("Accept", "application/json");
 
        if (formData != null)
        {
            request.Content = new FormUrlEncodedContent(formData!);
        }
 
        return await Backchannel.SendAsync(request, Context.RequestAborted);
    }
 
    private async Task<RequestToken> ObtainRequestTokenAsync(string callBackUri, AuthenticationProperties properties)
    {
        Logger.ObtainRequestToken();
 
        var response = await ExecuteRequestAsync(TwitterDefaults.RequestTokenEndpoint, HttpMethod.Post, extraOAuthPairs: new Dictionary<string, string>() { { "oauth_callback", callBackUri } });
        await EnsureTwitterRequestSuccess(response);
        var responseText = await response.Content.ReadAsStringAsync(Context.RequestAborted);
 
        var responseParameters = new FormCollection(new FormReader(responseText).ReadForm());
        if (!string.Equals(responseParameters["oauth_callback_confirmed"], "true", StringComparison.Ordinal))
        {
            throw new AuthenticationFailureException("Twitter oauth_callback_confirmed is not true.");
        }
 
        return new RequestToken
        {
            Token = Uri.UnescapeDataString(responseParameters["oauth_token"].ToString()),
            TokenSecret = Uri.UnescapeDataString(responseParameters["oauth_token_secret"].ToString()),
            CallbackConfirmed = true,
            Properties = properties,
        };
    }
 
    private async Task<AccessToken> ObtainAccessTokenAsync(RequestToken token, string verifier)
    {
        // https://developer.twitter.com/en/docs/authentication/api-reference/access_token
 
        Logger.ObtainAccessToken();
 
        var formPost = new Dictionary<string, string> { { "oauth_verifier", verifier } };
        var response = await ExecuteRequestAsync(TwitterDefaults.AccessTokenEndpoint, HttpMethod.Post, token, formData: formPost);
 
        if (!response.IsSuccessStatusCode)
        {
            Logger.LogError("AccessToken request failed with a status code of " + response.StatusCode);
            await EnsureTwitterRequestSuccess(response); // throw
        }
 
        var responseText = await response.Content.ReadAsStringAsync(Context.RequestAborted);
        var responseParameters = new FormCollection(new FormReader(responseText).ReadForm());
 
        return new AccessToken
        {
            Token = Uri.UnescapeDataString(responseParameters["oauth_token"].ToString()),
            TokenSecret = Uri.UnescapeDataString(responseParameters["oauth_token_secret"].ToString()),
            UserId = Uri.UnescapeDataString(responseParameters["user_id"].ToString()),
            ScreenName = Uri.UnescapeDataString(responseParameters["screen_name"].ToString()),
        };
    }
 
    // https://dev.twitter.com/rest/reference/get/account/verify_credentials
    private async Task<JsonDocument> RetrieveUserDetailsAsync(AccessToken accessToken)
    {
        Logger.RetrieveUserDetails();
 
        var response = await ExecuteRequestAsync("https://api.twitter.com/1.1/account/verify_credentials.json", HttpMethod.Get, accessToken, queryParameters: new Dictionary<string, string>() { { "include_email", "true" } });
 
        if (!response.IsSuccessStatusCode)
        {
            Logger.LogError("Email request failed with a status code of " + response.StatusCode);
            await EnsureTwitterRequestSuccess(response); // throw
        }
        var responseText = await response.Content.ReadAsStringAsync(Context.RequestAborted);
 
        var result = JsonDocument.Parse(responseText);
 
        return result;
    }
 
    private string GenerateTimeStamp()
    {
        var secondsSinceUnixEpocStart = TimeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch;
        return Convert.ToInt64(secondsSinceUnixEpocStart.TotalSeconds).ToString(CultureInfo.InvariantCulture);
    }
 
    private static string ComputeSignature(string consumerSecret, string? tokenSecret, string signatureData)
    {
        var key = Encoding.ASCII.GetBytes(
            string.Format(CultureInfo.InvariantCulture,
                "{0}&{1}",
                Uri.EscapeDataString(consumerSecret),
                string.IsNullOrEmpty(tokenSecret) ? string.Empty : Uri.EscapeDataString(tokenSecret)));
        var hash = HMACSHA1.HashData(key, Encoding.ASCII.GetBytes(signatureData));
        return Convert.ToBase64String(hash);
    }
 
    // https://developer.twitter.com/en/docs/apps/callback-urls
    private async Task EnsureTwitterRequestSuccess(HttpResponseMessage response)
    {
        var contentTypeIsJson = string.Equals(response.Content.Headers.ContentType?.MediaType ?? "", "application/json", StringComparison.OrdinalIgnoreCase);
        if (response.IsSuccessStatusCode || !contentTypeIsJson)
        {
            // Not an error or not JSON, ensure success as usual
            response.EnsureSuccessStatusCode();
            return;
        }
 
        TwitterErrorResponse? errorResponse;
        try
        {
            // Failure, attempt to parse Twitters error message
            var errorContentStream = await response.Content.ReadAsStreamAsync(Context.RequestAborted);
            errorResponse = await JsonSerializer.DeserializeAsync(errorContentStream, TwitterJsonContext.DefaultWithOptions.TwitterErrorResponse);
        }
        catch
        {
            // No valid Twitter error response, throw as normal
            response.EnsureSuccessStatusCode();
            return;
        }
 
        if (errorResponse == null)
        {
            // No error message body
            response.EnsureSuccessStatusCode();
            return;
        }
 
        var errorMessageStringBuilder = new StringBuilder("An error has occurred while calling the Twitter API, error's returned:");
 
        if (errorResponse.Errors != null)
        {
            foreach (var error in errorResponse.Errors)
            {
                errorMessageStringBuilder.Append(Environment.NewLine);
                errorMessageStringBuilder.Append(CultureInfo.InvariantCulture, $"Code: {error.Code}, Message: '{error.Message}'");
            }
        }
 
        throw new InvalidOperationException(errorMessageStringBuilder.ToString());
    }
 
    [JsonSerializable(typeof(TwitterErrorResponse))]
    internal sealed partial class TwitterJsonContext : JsonSerializerContext
    {
        public static readonly TwitterJsonContext DefaultWithOptions = new TwitterJsonContext(new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true
        });
    }
}