DEV Community

Cover image for Adding Google Login to IdentityServer
Joseph Ndungi
Joseph Ndungi

Posted on

Adding Google Login to IdentityServer

Comedy of Errors

I recently added Google login to an Angular app backed by IdentityServer. Simple enough on paper. Took me almost two days.


The Setup

Stack: Angular 20 (standalone), angular-auth-oidc-client v21, IdentityServer8, ASP.NET Core. Google OAuth configured on the backend via .AddGoogle().

Goal: Add a "Continue with Google" button alongside existing credentials login. One button. How hard could it be.


Mistake 1: Wrong endpoint

First attempt pointed at /Account/ExternalLogin. 404. The actual controller was ExternalController with a Challenge action. Check your backend before assuming standard IdentityServer scaffolding.


Mistake 2: Wrong returnUrl

Passing http://localhost:4200/auth/callback as the returnUrl to the challenge endpoint. This made the backend sign the user in and redirect straight to Angular with absolutely no authorization code attached. Token: nowhere. Dashboard: unreachable.

The returnUrl must be the full OIDC authorize URL, not your Angular callback. IdentityServer needs to complete the code flow first, then redirect to Angular with ?code=XXX.

The fix that cracked this open was urlHandler:

this.oidcSecurityService.authorize(undefined, {
  customParams: {
    acr_values: 'idp:Google',
    prompt: 'login',
  },
  urlHandler: (authorizeUrl) => {
    const challengeUrl =
      `${environment.config.STSURL}/External/Challenge?` +
      `scheme=Google&` +
      `returnUrl=${encodeURIComponent(authorizeUrl)}`;
    window.location.href = challengeUrl;
  },
});
Enter fullscreen mode Exit fullscreen mode

urlHandler intercepts the authorize URL the library builds and lets you redirect somewhere else first. In this case, the IdentityServer challenge endpoint. IdentityServer handles Google, comes back, signs the user in, then hits /connect/authorize with an active session and issues the code. Angular gets ?code=XXX. Library exchanges it. Token arrives. Dashboard loads.


Mistake 3: checkAuth() everywhere

Calling checkAuth() in AppComponent, the callback component, and the guard simultaneously caused a race condition. The library was trying to process the auth code in multiple places at once and losing.

Rule: call checkAuth() once at app init, skip it on the callback route, let the callback component handle its own:

// AppComponent
ngOnInit(): void {
  const currentUrl = window.location.pathname;
  if (currentUrl === '/auth/callback') return;  // Let CallbackComponent handle this

  this.oidcSecurityService.checkAuth().subscribe({
    next: ({ isAuthenticated }) => {
      if (isAuthenticated && (currentUrl === '/' || currentUrl === '/login')) {
        this.router.navigateByUrl('/dashboard');
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode
// CallbackComponent
ngOnInit(): void {
  this.oidcSecurityService.checkAuth().subscribe(({ isAuthenticated }) => {
    this.router.navigate([isAuthenticated ? '/dashboard' : '/login']);
  });
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: startCheckSession: true

This loads IdentityServer in a hidden iframe to poll session state. IdentityServer has frame-ancestors 'none' in its CSP. The iframe gets blocked. Turn it off unless you specifically need session monitoring:

startCheckSession: false,
silentRenew: false,
triggerRefreshWhenIdTokenExpired: false,
Enter fullscreen mode Exit fullscreen mode

Mistake 5: silentRenewUrl defaulting to https://please_set

With silentRenew: false you'd expect this not to matter. It does. The library was still firing silent authorize requests with redirect_uri: https://please_set. IdentityServer rejected every one. Explicitly set it to your actual callback URL:

silentRenewUrl: environment.config.AuthCallbackUrl,
Enter fullscreen mode Exit fullscreen mode

Mistake 6: Config registered too late

Calling setupModule() inside AppComponent.ngOnInit() means the config isn't ready when the first OIDC calls fire. Register at bootstrap:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptorsFromDi()),
    provideAnimations(),
    importProvidersFrom(
      AuthModule.forRoot(oidcAuthConfig),
      ToastrModule.forRoot()
    )
  ]
});
Enter fullscreen mode Exit fullscreen mode
// oidc-auth.config.ts
export const oidcAuthConfig: PassedInitialConfig = {
  config: {
    authority: environment.config.STSURL,
    authWellknownEndpointUrl: `${environment.config.STSURL}/.well-known/openid-configuration`,
    redirectUrl: environment.config.AuthCallbackUrl,
    postLogoutRedirectUri: environment.config.MatrixUIURL,
    clientId: environment.config.MatrixUIClientIdAsInSTS,
    scope: 'openid profile email api1',
    responseType: 'code',
    silentRenew: false,
    useRefreshToken: false,
    startCheckSession: false,
    triggerRefreshWhenIdTokenExpired: false,
    silentRenewUrl: environment.config.AuthCallbackUrl,
    disableIatOffsetValidation: false,
    maxIdTokenIatOffsetAllowedInSeconds: 600,
    logLevel: LogLevel.Debug,
  },
};
Enter fullscreen mode Exit fullscreen mode

The auth guard: keep it simple

Use isAuthenticated$ directly, not checkAuth(). Calling checkAuth() in the guard fires another authorize request:

canActivate(): Observable<boolean | UrlTree> {
  return this.oidcSecurityService.isAuthenticated$.pipe(
    take(1),
    map(({ isAuthenticated }) =>
      isAuthenticated ? true : this.router.createUrlTree(['/login'])
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

What the full flow looks like when it works

Click "Login with Google"
  → library builds authorize URL
  → urlHandler sends it to /External/Challenge?scheme=Google&returnUrl=<authorize url>
  → IdentityServer challenges Google
  → user authenticates
  → ExternalController.Callback signs user into IdentityServer session
  → redirects to /connect/authorize (the returnUrl)
  → IdentityServer sees active session, issues authorization code
  → redirects to http://localhost:4200/auth/callback?code=XXX
  → CallbackComponent calls checkAuth()
  → library exchanges code for token
  → isAuthenticated$ emits true
  → dashboard
Enter fullscreen mode Exit fullscreen mode

Two days. One urlHandler callback. Happy Life.

Happy Coding!

Top comments (0)