Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Google login in Angular 7 with .NET Core API

I'm trying to implement Google login in my Angular application. If I try to call api endpoint for external login server return 405 error code like this:

Access to XMLHttpRequest at 'https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=...' (redirected from 'http://localhost:5000/api/authentication/externalLogin?provider=Google') from origin 'null' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

If I call api/authentication/externalLogin?provider=Google in new browser tab all work correctly. I thing that the problem is in angular code.

My api works on localhost:5000. Angular app works on localhost:4200. I use .net core 2.1 and Angular 7

C# code

Startup.cs

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(x =>
{
    x.RequireHttpsMetadata = false;
    x.SaveToken = true;
    x.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = false,
        ValidateAudience = false
    };
})
.AddCookie()
.AddGoogle(options => {
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.ClientId = "xxx";
    options.ClientSecret = "xxx";
    options.Scope.Add("profile");
    options.Events.OnCreatingTicket = (context) =>
    {
        context.Identity.AddClaim(new Claim("image", context.User.GetValue("image").SelectToken("url").ToString()));

        return Task.CompletedTask;
    };
});

AuthenticationController.cs

[HttpGet]
public IActionResult ExternalLogin(string provider)
{
    var callbackUrl = Url.Action("ExternalLoginCallback");
    var authenticationProperties = new AuthenticationProperties { RedirectUri = callbackUrl };
    return this.Challenge(authenticationProperties, provider);
}

[HttpGet]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
{
    var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

    return this.Ok(new
    {
        NameIdentifier = result.Principal.FindFirstValue(ClaimTypes.NameIdentifier),
        Email = result.Principal.FindFirstValue(ClaimTypes.Email),
        Picture = result.Principal.FindFirstValue("image")
    });
}

Angular code

login.component.html

<button (click)="googleLogIn()">Log in with Google</button>

login.component.ts

googleLogIn() {
  this.authenticationService.loginWithGoogle()
  .pipe(first())
  .subscribe(
    data => console.log(data)
  );
}

authentication.service.ts

public loginWithGoogle() {
  return this.http.get<any>(`${environment.api.apiUrl}${environment.api.authentication}externalLogin`,
  {
    params: new HttpParams().set('provider', 'Google'),
    headers: new HttpHeaders()
      .set('Access-Control-Allow-Headers', 'Content-Type')
      .set('Access-Control-Allow-Methods', 'GET')
      .set('Access-Control-Allow-Origin', '*')
  })
  .pipe(map(data => {
    return data;
  }));
}

I imagine the following scheme: Angular -> My API -> redirect to Google -> google return user data to my api -> My API return JWT token -> Angular use token

Could you help me with this problem.

like image 878
totkov Avatar asked Feb 14 '19 16:02

totkov


1 Answers

The problem seems to be that although the server is sending a 302 response (url redirection) Angular is making an XMLHttpRequest, it's not redirecting. There is more people having this issue...

For me trying to intercept the response in the frontend to make a manual redirection or changing the response code on the server (it is a 'Challenge' response..) didn't work.

So what I did to make it work was change in Angular the window.location to the backend service so the browser can manage the response and make the redirection properly.

NOTE: At the end of the post I explain a more straightforward solution for SPA applications without the use of cookies or AspNetCore Authentication.

The complete flow would be this:

(1) Angular sets browser location to the API -> (2) API sends 302 response --> (3) Browser redirects to Google -> (4) Google returns user data as cookie to API -> (5) API returns JWT token -> (6) Angular use token

1.- Angular sets browser location to the API. We pass the provider and the returnURL where we want the API to return the JWT token when the process has ended.

import { DOCUMENT } from '@angular/common';
...
 constructor(@Inject(DOCUMENT) private document: Document, ...) { }
...
  signInExternalLocation() {
    let provider = 'provider=Google';
    let returnUrl = 'returnUrl=' + this.document.location.origin + '/register/external';

    this.document.location.href = APISecurityRoutes.authRoutes.signinexternal() + '?' + provider + '&' + returnUrl;
  }

2.- API sends 302 Challenge response. We create the redirection with the provider and the URL where we want Google call us back.

// GET: api/auth/signinexternal
[HttpGet("signinexternal")]
public IActionResult SigninExternal(string provider, string returnUrl)
{
    // Request a redirect to the external login provider.
    string redirectUrl = Url.Action(nameof(SigninExternalCallback), "Auth", new { returnUrl });
    AuthenticationProperties properties = _signInMgr.ConfigureExternalAuthenticationProperties(provider, redirectUrl);

    return Challenge(properties, provider);
}

5.- API receives google user data and returns JWT token. In the querystring we will have the Angular return URL. In my case if the user is not registered I was doing an extra step to ask for permission.

// GET: api/auth/signinexternalcallback
[HttpGet("signinexternalcallback")]
public async Task<IActionResult> SigninExternalCallback(string returnUrl = null, string remoteError = null)
{
    //string identityExternalCookie = Request.Cookies["Identity.External"];//do we have the cookie??

    ExternalLoginInfo info = await _signInMgr.GetExternalLoginInfoAsync();

    if (info == null)  return new RedirectResult($"{returnUrl}?error=externalsigninerror");

    // Sign in the user with this external login provider if the user already has a login.
    Microsoft.AspNetCore.Identity.SignInResult result = 
        await _signInMgr.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);

    if (result.Succeeded)
    {
        CredentialsDTO credentials = _authService.ExternalSignIn(info);
        return new RedirectResult($"{returnUrl}?token={credentials.JWTToken}");
    }

    if (result.IsLockedOut)
    {
        return new RedirectResult($"{returnUrl}?error=lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an account.

        string loginprovider = info.LoginProvider;
        string email = info.Principal.FindFirstValue(ClaimTypes.Email);
        string name = info.Principal.FindFirstValue(ClaimTypes.GivenName);
        string surname = info.Principal.FindFirstValue(ClaimTypes.Surname);

        return new RedirectResult($"{returnUrl}?error=notregistered&provider={loginprovider}" +
            $"&email={email}&name={name}&surname={surname}");
    }
}

API for the registration extra step (for this call Angular has to make the request with 'WithCredentials' in order to receive the cookie):

[HttpPost("registerexternaluser")]
public async Task<IActionResult> ExternalUserRegistration([FromBody] RegistrationUserDTO registrationUser)
{
    //string identityExternalCookie = Request.Cookies["Identity.External"];//do we have the cookie??

    if (ModelState.IsValid)
    {
        // Get the information about the user from the external login provider
        ExternalLoginInfo info = await _signInMgr.GetExternalLoginInfoAsync();

        if (info == null) return BadRequest("Error registering external user.");

        CredentialsDTO credentials = await _authService.RegisterExternalUser(registrationUser, info);
        return Ok(credentials);
    }

    return BadRequest();
}

Different approach for SPA applications:

Just when i finished making it work i found that for SPA applications there is a better way of doing it (https://developers.google.com/identity/sign-in/web/server-side-flow, Google JWT Authentication with AspNet Core 2.0, https://medium.com/mickeysden/react-and-google-oauth-with-net-core-backend-4faaba25ead0 )

For this approach the flow would be:

(1) Angular opens google authentication -> (2) User authenticates --> (3) Google sends googleToken to angular -> (4) Angular sends it to the API -> (5) API validates it against google and returns JWT token -> (6) Angular uses token

For this we need to install the 'angularx-social-login' npm package in Angular and the 'Google.Apis.Auth' NuGet package in the netcore backend

1. and 4. - Angular opens google authentication. We will use the angularx-social-login library. After user sings in Angular sends the googletoken to the API.

On the login.module.ts we add:

let config = new AuthServiceConfig([
  {
    id: GoogleLoginProvider.PROVIDER_ID,
    provider: new GoogleLoginProvider('Google ClientId here!!')
  }
]);

export function provideConfig() {
  return config;
}

@NgModule({
  declarations: [
...
  ],
  imports: [
...
  ],
  exports: [
...
  ],
  providers: [
    {
      provide: AuthServiceConfig,
      useFactory: provideConfig
    }
  ]
})

On our login.component.ts:

import { AuthService, GoogleLoginProvider } from 'angularx-social-login';
...
  constructor(...,  private socialAuthService: AuthService)
...

  signinWithGoogle() {
    let socialPlatformProvider = GoogleLoginProvider.PROVIDER_ID;
    this.isLoading = true;

    this.socialAuthService.signIn(socialPlatformProvider)
      .then((userData) => {
        //on success
        //this will return user data from google. What you need is a user token which you will send it to the server
        this.authenticationService.googleSignInExternal(userData.idToken)
          .pipe(finalize(() => this.isLoading = false)).subscribe(result => {

            console.log('externallogin: ' + JSON.stringify(result));
            if (!(result instanceof SimpleError) && this.credentialsService.isAuthenticated()) {
              this.router.navigate(['/index']);
            }
        });
      });
  }

On our authentication.service.ts:

  googleSignInExternal(googleTokenId: string): Observable<SimpleError | ICredentials> {

    return this.httpClient.get(APISecurityRoutes.authRoutes.googlesigninexternal(), {
      params: new HttpParams().set('googleTokenId', googleTokenId)
    })
      .pipe(
        map((result: ICredentials | SimpleError) => {
          if (!(result instanceof SimpleError)) {
            this.credentialsService.setCredentials(result, true);
          }
          return result;

        }),
        catchError(() => of(new SimpleError('error_signin')))
      );

  }

5.- API validates it against google and returns JWT token. We will be using the 'Google.Apis.Auth' NuGet package. I won't put the full code for this but make sure that when you validate de token you add the audience to the settings for a secure signin:

 private async Task<GoogleJsonWebSignature.Payload> ValidateGoogleToken(string googleTokenId)
    {
        GoogleJsonWebSignature.ValidationSettings settings = new GoogleJsonWebSignature.ValidationSettings();
        settings.Audience = new List<string>() { "Google ClientId here!!" };
        GoogleJsonWebSignature.Payload payload = await GoogleJsonWebSignature.ValidateAsync(googleTokenId, settings);
        return payload;
    }
like image 193
Javi Avatar answered Oct 25 '22 01:10

Javi