Twitter OAuth with SS Razor Template

Created a new project with x new razor and am trying to setup a Twitter OAuth flow however when it redirects back from Twitter my site just shows a forbidden message. Any help is appreciated:

I took the default setup like so and this was the only change from the template was removing the auth providers and adding the Twitter one. No other changes were made (6.10 project)

The Callback Urls are correct (https://localhost:44300 and https://localhost:44300/auth/twitter)

public class ConfigureAuth : IHostingStartup
{
    public void Configure(IWebHostBuilder builder) => builder
        .ConfigureServices(services => {
            //services.AddSingleton<ICacheClient>(new MemoryCacheClient()); //Store User Sessions in Memory Cache (default)
        })
        .ConfigureAppHost(appHost => {
            var appSettings = appHost.AppSettings;
            appHost.Plugins.Add(new AuthFeature(() => new CustomUserSession(),
                new IAuthProvider[] {
                    new CredentialsAuthProvider(appSettings),     /* Sign In with Username / Password credentials */
                    new TwitterAuthProvider(appSettings)
                    {
                        CallbackUrl = appSettings.GetString("oauth.twitter.CallbackUrl"),
                        RedirectUrl = appSettings.GetString("oauth.twitter.RedirectUrl"),   
                        ConsumerKey = appSettings.GetString("oauth.twitter.ConsumerKey"),
                        ConsumerSecret = appSettings.GetString("oauth.twitter.ConsumerSecret"),
                        
                    }
                }) {
                    HtmlRedirect = "/signin"
                });

            appHost.Plugins.Add(new RegistrationFeature()); //Enable /register Service

            //override the default registration validation with your own custom implementation
            appHost.RegisterAs<CustomRegistrationValidator, IValidator<Register>>();
        });
}

After the Twitter OAuth process it redirects back to my site and shows this in the browser:

Forbidden

Request.HttpMethod: GET
Request.PathInfo: /auth/twitter
Request.QueryString: oauth_token=_hzkYAAAAAABc0F0AAAredacted&oauth_verifier=qsiP75I0gmoRIDHezR6redacted

Request.RawUrl: /auth/twitter?oauth_token=_hzkYAAAAAAsdfasfdasdfaredactONIo9k&oauth_verifier=qsiP75I0gmoRIDHezR6Vredacted

Something looks very off, if there was a failed integration with Twitter OAuth APIs it would result in a redirect with ?f=ErrorCode query string. Instead the API returns a 403 Forbidden error but the TwitterAuthProvider.cs only throws 401 Unauthorized errors when the AccessToken is invalid so it doesn’t appear to come from the Twitter Auth Provider itself.

It may be an issue with Twitter OAuth not supporting OAuth scope so it needs to rely on 3rd Party cookies which should be supported with the default Config.UseSameSiteCookies==null which specifies Lax SameSite cookies, but you should double check that the cookies for the /auth/twitter callback remain the same as the initial request, since it needs the session to be able to correlate the requests and authenticate the user.

Other things I can suggest trying is checking the error logs in Debug/Development mode to see if there’s any Exception StackTraces or logs to provide any insight. Use a HTTP Sniffer like Wireshark to inspect whether the API call to twitter oauth succeeded or if there are any failed HTTP Error responses, try debugging into the source code to see where the Forbidden error is being thrown.

You can also try using different Twitter OAuth credentials, e.g. if you change to use port 5000 you can try using ServiceStack’s development Twitter OAuth:

{
  "oauth.RedirectUrl": "https://localhost:5001/",
  "oauth.CallbackUrl": "https://localhost:5001/auth/{0}",
  "oauth.twitter.ConsumerKey": "JvWZokH73rdghDdCFCFkJtCEU",
  "oauth.twitter.ConsumerSecret": "WNeOT6YalxXDR4iWZjc4jVjFaydoDcY8jgRrGc5FVLjsVlY2Y8",
}

BTW you don’t need to populate the TwitterAuthProvider since you’re using the keys that is populated by default, so you can leave it as:

new IAuthProvider[] {
    new CredentialsAuthProvider(appSettings),
    new TwitterAuthProvider(appSettings),
}

Can you show how you have the Twitter App setup? I’ve added the callback uris/redirect URLs and am using the clientid/secret and it won’t authenticate with mine. It does work with the test values you added so I’d like to see how you have the twitter app setup in their portal if at all possible.

After further analysis, I could NOT get this to work with a new Twitter App in their new portal. I found an old app that I could use in their deprecated portal and it works fine. Can you please try creating a new developer twitter app with this TwiterAuthProfider as I don’t think it is compatible or at least I could not determine how to get it to work.

The old portal will give you this message:

We have sunset apps.twitter.com. You can manage any of your existing Apps in all of the same ways through this site.

@lucuma I can also reproduce this.

It seems like this deprecation by twitter has broken their OAuth 1.0 integration with some small changes, which is worsened by their documentation. Specifically this part.

Terminology clarification
In the guide below, you may see different terms referring to the same thing.
...

Temporary credentials:

Request Token === oauth_token
Request Token Secret === oauth_token_secret
oauth_verifier

It seems the previous implementation they populated both the RequestTokenSecret and the oauth_token_secret but they now only populate the oauth_token_secret. The OAuthUtils.AcquireAccessToken method only needs the oauth_token and oauth_verifier (which are now the only things passed back) to then successfully fetch the temporary access token.

I was seeing an infinite loop due to the following if check in the current implementation.

Another issue with the current implementation is the use of session.ReferrerUrl which in my testing I found was populated with api.twitter.com when in actual fact it is expected to return to your own domain. Current flow is either expects persisting session.ReferrerUrl from previous request and then falls back to original Request which on the redirect from Twitter is api.twitter.com. This could be a problem with my test setup though, not 100% sure.

Created a minimal reproduction on GitHub here with my test app keys for ease of use.

In my testing, I created a new OAuthProvider inheriting from TwitterOAuth and provided an override for AuthenticateAsync with just these two changes. More testing will be needed to make sure both can ‘sunset’ apps and updated OAuth 1.0 apps can be supported side by side or if a separate OAuth provider is required.

Here is my override with small changes above and Twitter App settings for context.

public class TwitterUpdateOAuthProvider : TwitterAuthProvider
{
    public TwitterUpdateOAuthProvider(IAppSettings appSettings) : base(appSettings)
    {

    }
    public override async Task<object> AuthenticateAsync(IServiceBase authService, IAuthSession session, Authenticate request,
        CancellationToken token = new CancellationToken())
    {
        var tokens = Init(authService, ref session, request);
        var ctx = CreateAuthContext(authService, session, tokens);

        //Transferring AccessToken/Secret from Mobile/Desktop App to Server
        if (request.AccessToken != null && request.AccessTokenSecret != null)
        {
            tokens.AccessToken = request.AccessToken;
            tokens.AccessTokenSecret = request.AccessTokenSecret;

            var validToken = await AuthHttpGateway.VerifyTwitterAccessTokenAsync(
                ConsumerKey, ConsumerSecret,
                tokens.AccessToken, tokens.AccessTokenSecret, token).ConfigAwait();

            if (validToken == null)
                return HttpError.Unauthorized("AccessToken is invalid");

            if (!string.IsNullOrEmpty(request.UserName) && validToken.UserId != request.UserName)
                return HttpError.Unauthorized("AccessToken does not match UserId: " + request.UserName);

            tokens.UserId = validToken.UserId;
            session.IsAuthenticated = true;

            var failedResult = await OnAuthenticatedAsync(authService, session, tokens, new Dictionary<string, string>(), token).ConfigAwait();
            var isHtml = authService.Request.IsHtml();
            if (failedResult != null)
                return ConvertToClientError(failedResult, isHtml);

            return isHtml
                ? await authService.Redirect(SuccessRedirectUrlFilter(ctx, RedirectUrl.SetParam("s", "1"))).SuccessAuthResultAsync(authService,session).ConfigAwait()
                : null; //return default AuthenticateResponse
        }
        
        //Default OAuth logic based on Twitter's OAuth workflow
        if ((!tokens.RequestTokenSecret.IsNullOrEmpty() && !request.oauth_token.IsNullOrEmpty()) || 
            (!request.oauth_token.IsNullOrEmpty() && !request.oauth_verifier.IsNullOrEmpty()))
        {
            if (OAuthUtils.AcquireAccessToken(tokens.RequestTokenSecret, request.oauth_token, request.oauth_verifier))
            {
                session.IsAuthenticated = true;
                tokens.AccessToken = OAuthUtils.AccessToken;
                tokens.AccessTokenSecret = OAuthUtils.AccessTokenSecret;

                //Haz Access
                return await OnAuthenticatedAsync(authService, session, tokens, OAuthUtils.AuthInfo, token).ConfigAwait()
                    ?? await authService.Redirect(SuccessRedirectUrlFilter(ctx, this.RedirectUrl.SetParam("s", "1"))).SuccessAuthResultAsync(authService,session).ConfigAwait();
            }

            //No Joy :(
            tokens.RequestToken = null;
            tokens.RequestTokenSecret = null;
            await this.SaveSessionAsync(authService, session, SessionExpiry, token).ConfigAwait();
            return authService.Redirect(FailedRedirectUrlFilter(ctx, RedirectUrl.SetParam("f", "AccessTokenFailed")));
        }
        if (OAuthUtils.AcquireRequestToken())
        {
            tokens.RequestToken = OAuthUtils.RequestToken;
            tokens.RequestTokenSecret = OAuthUtils.RequestTokenSecret;
            await this.SaveSessionAsync(authService, session, SessionExpiry, token).ConfigAwait();

            //Redirect to OAuth provider to approve access
            return authService.Redirect(AccessTokenUrlFilter(ctx, this.AuthorizeUrl
                .AddQueryParam("oauth_token", tokens.RequestToken)
                .AddQueryParam("oauth_callback", session.ReferrerUrl)
                .AddQueryParam(Keywords.State, session.Id) // doesn't support state param atm, but it's here when it does
            ));
        }

        return authService.Redirect(FailedRedirectUrlFilter(ctx, session.ReferrerUrl.SetParam("f", "RequestTokenFailed")));
    }
}

Usage:

appHost.Plugins.Add(new AuthFeature(() => new CustomUserSession(),
    new IAuthProvider[] {
        new CredentialsAuthProvider(appSettings),     /* Sign In with Username / Password credentials */
        new TwitterUpdateOAuthProvider(appSettings)
        {
            ConsumerKey = "key",
            ConsumerSecret = "secret",
            RedirectUrl = "https://localhost:5001/",
            CallbackUrl = "https://localhost:5001/auth/twitter"
        }
    }));

@lucuma after much head scratching due to environment specific things, for me at least it comes down to as @mythz said the default in templates of UseSameSiteCookies = true. This basically means on redirect from authentication in twitter you are getting a new session and RequestTokenSecret is null. This is in the docs under sessions.

Something to make sure is to be using the OAuth 1.0a Consumer Keys not the OAuth2 ClientID/Client Secret. I did get stuck at one point where I needed to regenerate keys but I could have been using the incorrect ones.

I tested with both OAuth2 + OAuth1.0a enabled and only OAuth1.0a and both worked once UseSameSiteCookie was set to null.

I’ve updated my example in GitHub so you can see a working setup, and see the screen shot in the above post for my new app settings.

Give that a go and let us know if you are still having issues and if you can provide a reproduction.

1 Like

Thank you. I will review what you posted this week. Appreciate the team looking into it as Twitter makes changes it isn’t making it easy on us!

Coming back to this, the suggestions above unfortunately don’t work. I tried today (Oct 6, 2022) with a new Razor template and a Blazor one and setting the SameSiteCookies to null still ends up with a forbidden with the newer Twitter Apps. I tried the posted code as well.

To reproduce just x new Razor and change the Config.SameSiteCookies to null. It didn’t work, same with the blazor templates.

I also tried with an older Twitter App from a few years ago and it does work.