angular-auth-oidc-client icon indicating copy to clipboard operation
angular-auth-oidc-client copied to clipboard

[Question]: Endless Redirect Loop After Authentication in Standalone Angular App

Open arharutyu opened this issue 1 year ago • 10 comments

What Version of the library are you using? 18.0.2

Description:

I'm using the angular-auth-oidc-client library in my standalone Angular app (version 18.2.8), with no Angular modules. When I call checkAuth() at the app entry point (app.component.ts), the following issue occurs:

  1. After successful authentication, I am redirected back to the /callback endpoint, which is unprotected.
  2. When I attempt to redirect in callback component to the correct protected route, I end up in an endless redirect loop between /callback and IDP.
  3. If the callback component/route is empty, the redirect works as expected, going from /callback to the root route (home).

Expected Behavior: After successful authentication, the user should be redirected to the originally requested protected route, not stuck in a redirect loop.

Current Behavior: After authentication, the app is stuck in a redirect loop between /callback and IDP.

Steps to Reproduce:

  1. Use angular-auth-oidc-client v18.0.2 in a standalone Angular app (no Angular modules).
  2. /callback is unprotected, all other routes are protected, with '/' redirecting to '/home'
  3. Call checkAuth() in the app.component.ts.
  4. After successful authentication, the app redirects to the /callback route.
  5. If I attempt to redirect to a protected route within callback, it results in an endless redirect loop.
  6. If the callback component/route is empty, the redirect properly takes me to the home route (root).

Environment: Angular version: 18.2.8 angular-auth-oidc-client version: 18.0.2 Browser: Chrome or Edge

What I've Tried: Ensured the callback route is correctly configured in the routing module. Tried adding logic in the callback component to navigate to the required route, but it still results in the redirect loop.

Questions: Why is this endless redirect loop happening? How can I ensure that the callback works as expected and navigate to the protected route after authentication?

Code Snippets: Relevant code snippets provided, further can be provided if needed!

app.routes.ts

export const routes: Routes = [
    {
        path: '',
        children:
            [
                {
                    path: 'callback',
                    component: CallbackComponent,
                },
		// ... other unprotected routes
                {
                    path: '',
                    component: LayoutComponent,
                    canActivateChild: [authGuard],
                    children: [
                        {
                            path: '',
                            redirectTo: 'home',
                            pathMatch: 'full'
                        },
                        {
                            path: 'home',
                            component: HomeComponent,
                        },
			// ... other protected routes
                    ]
                }
            ]
    }
];

app.component.ts

  private readonly AuthService = inject(AuthService);

  ngOnInit() {
    this.AuthService.initializeAuth();
  }

auth.service.ts

  constructor() {
    this.oidcSecurityService.isAuthenticated$.subscribe(
      (result) => {
        this._isAuthenticated$.next(result.isAuthenticated);
      }
    );
  }

  initializeAuth() {
    this.oidcSecurityService.checkAuth().subscribe(({ isAuthenticated, userData}) => {
      console.log(isAuthenticated);
      console.log(userData)
    });
  }

arharutyu avatar Nov 24 '24 21:11 arharutyu

@arharutyu after moving from version 18.0.1 to 18.0.2 I had the same behavior. After some debuging I learned that a new config property was introduced in 18.02 which caused the issue for me:

The new property OpenIdConfiguration.checkRedirectUrlWhenCheckingIfIsCallback (defaults to true) enables a newly introduced feature in UrlService.isCallbackFromSts that will do a more sophisticated check to determine whether the provided URL represents the redirect URL and therefore should be treated as a callback.

Setting this property to false solved my problem as it essentially reverts back to the same logic that was applied in version 18.0.1.

Symptomy of the problem seems to be the same, but not sure if the root cause is the same, but I thought it is worth sharing.

spunzmann avatar Nov 29 '24 11:11 spunzmann

@arharutyu after moving from version 18.0.1 to 18.0.2 I had the same behavior. After some debuging I learned that a new config property was introduced in 18.02 which caused the issue for me:

The new property OpenIdConfiguration.checkRedirectUrlWhenCheckingIfIsCallback (defaults to true) enables a newly introduced feature in UrlService.isCallbackFromSts that will do a more sophisticated check to determine whether the provided URL represents the redirect URL and therefore should be treated as a callback.

Setting this property to false solved my problem as it essentially reverts back to the same logic that was applied in version 18.0.1.

Symptomy of the problem seems to be the same, but not sure if the root cause is the same, but I thought it is worth sharing.

This solved my problem too. I had this and lost two entire days looking for a solution without success.

@spunzmann how did you discover that ?

leopoliveira avatar Dec 06 '24 13:12 leopoliveira

Hello @arharutyu, is this maybe similar to my issue #2040?

rammba avatar Jan 06 '25 14:01 rammba

@spunzmann , how do you set OpenIdConfiguration.checkRedirectUrlWhenCheckingIfIsCallback to false?

jaigtz88 avatar Jan 07 '25 08:01 jaigtz88

@leopoliveira I simply debugged my application with the browsers dev tools and eventually figured it out. I got lucky I guess ;-)

@jaigtz88 the property checkRedirectUrlWhenCheckingIfIsCallback is defined in the OpenIdConfiguration interface. So you set it wherever you define the OpenIdConfiguration in your application. This property has been introduced with 18.0.2

spunzmann avatar Jan 07 '25 10:01 spunzmann

I have the same issue with 18.0.2. Coming from 17.x with a simple ng update …@18 yielded a broken login. I can confirm that the first steps work properly. The service requests the code from the OIDC issuer, also queries config via HTTP and does not proceed any further. With the property set to false it takes the issued code and fetches the token, too.

It gets even weirder with a filled localstorage. Since that token is very likely expired it gets loaded instead and it looks as if the user is not logged in.

This behavior is very hard to track and it took me 2 days to find this issue. I am curious, too, how this could slip through testing. Users of this library will not test this but use a mock for the service instead.

onkobu avatar Feb 05 '25 08:02 onkobu

As this issue cropped up when I was on a timeline and needed to implement redirect to the requested URL the user is accessing prior to login I went ahead with a workaround. By the time @spunzmann commented I had switched to dynamically loading config and for some reason their suggestion wasn't working for me either.

To avoid the endless redirect loop

  1. auth guard stores requested url in local storage before redirecting to login
  2. redirectUrl stays same to '/callback' (and app will go there after successful login then redirect to home)
  3. home component then redirects to requested URL from local storage

This is the relevant code I used in case it's useful for others:

auth-config-loader.factory.ts

export const httpLoaderFactory = (httpClient: HttpClient) => {
  const config$ = httpClient.get<any>('/assets/config.json').pipe(
    map((customConfig: any) => {
      return {
        authority: customConfig.authority,
        redirectUrl: window.location.origin + '/callback',
        postLogoutRedirectUri: window.location.origin + '/logout',
        clientId: 'xxx',
        scope: 'xxx',
        responseType: 'code',
        silentRenew: true,
        silentRenewUrl: window.location.origin + '/silent-renew.html',
        renewTimeBeforeTokenExpiresInSeconds: 10,
        checkRedirectUrlWhenCheckingIfIsCallback: false,
      };
    })
  );

  return new StsConfigHttpLoader(config$);
};

main.ts

import { provideAuth, StsConfigLoader } from 'angular-auth-oidc-client';

appConfig.providers = [
  ...(appConfig.providers || []),
  {
    provide: APP_INITIALIZER,
    useFactory: loadConfig,
    deps: [ConfigService],
    multi: true,
  },
  provideAuth({
    loader: {
      provide: StsConfigLoader,
      useFactory: httpLoaderFactory,
      deps: [HttpClient],
    }
  })
];

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

Updated AuthService (relevant functions only): auth.service.ts

  constructor(private router: Router) {
// initialize auth now called in app component to allow dynamic loading of config providing authority URL and configs
  }

  initializeAuth() {
    if (!this._isInitialized$.getValue()) {
      combineLatest([
        this.oidcSecurityService.checkAuth(),
        this.oidcSecurityService.getAccessToken(),
      ])
        .pipe(
          // filter out null values to avoid first API call after app start failing
          filter(([authResult, accessToken]) => authResult?.isAuthenticated && !!accessToken)
        )
        .subscribe({
          next: ([authResult, accessToken]) => {
            this._isAuthenticated$.next(authResult.isAuthenticated);
            this._accessToken$.next(accessToken);

            this._isInitialized$.next(true);
          },
          error: (error) => {
            console.error('Failed to initialize authentication:', error);
          },
        });
    }
  }

  postLoginRedirect() {
    const postLoginRedirectUrl = localStorage.getItem('postLoginRedirectUrl');
    if (postLoginRedirectUrl) {
      this.router.navigateByUrl(postLoginRedirectUrl);
      localStorage.removeItem('postLoginRedirectUrl');
    }
  }

auth.guard.ts

export const authGuard = () => {
  const authService = inject(AuthService);
  const router = inject(Router);

  return authService.isAuthenticated$.pipe(
    take(1),
    map(isAuthenticated => {
      if (isAuthenticated) {
        return true; // User is authenticated, allow route access
      } else {
        const postLoginRedirectUrl = router.getCurrentNavigation()?.extractedUrl.toString() || '/';
        localStorage.setItem('postLoginRedirectUrl', postLoginRedirectUrl); // capture url being navigated to for post login url
        authService.redirectToLogin(); // Redirect to login
        return false;
      }
    })
  );
};

app.component.ts

  private readonly AuthService = inject(AuthService);

  ngOnInit() {
    this.AuthService.initializeAuth();
  }

home.component.ts

  private authService = inject(AuthService)
  private idleKeepAliveService = inject(IdleKeepAliveService)

  constructor() {
    this.authService.postLoginRedirect();
    // initialize idle service after successful login
    this.idleKeepAliveService.initialize();
  }

arharutyu avatar Feb 12 '25 20:02 arharutyu

same issue occurred in angular 18.2.5 with CanActivateChild. but after more research i never found good solution. finally i use CanActivate Guard for each of router child objects. now it is working fine. if anyone have good solution please update me.

dmcchanaka avatar Mar 27 '25 11:03 dmcchanaka

I solved it by downgrading the package version.

My versions:

Angular: 19.2.5 angular-auth-oidc-client: "18.0.0",

And my configuration code looked like this:

export const authConfig: PassedInitialConfig = {
  config: {
  // URL of your identity provider
  authority: environment.authority,
  
  // Requested response type, as needed
  responseType: 'id_token token',
  
  // The library will process the token in the URL when the app loads.
  redirectUrl: 'http://localhost:4200/logged',
  
  // URL to which the user will be redirected after logging out (AUTO REDIRECT TO URL/
  postLogoutRedirectUri: window.location.origin,
  
  // Client ID registered with your identity provider.
  clientId: environment.client_id, // Using the Client ID you provided
  
  // Scopes your application needs. 'openid' is required.
  scope: 'openid profile email',
  
  // Enables silent token renewal in the background
  silentRenew: true,
  
  // Enables the use of refresh tokens to keep the user logged in
  useRefreshToken: true,
  
  // Automatically fetches user information after login
  autoUserInfo: true,
  }
}

My app.routes:

import { autoLoginPartialRoutesGuard } from 'angular-auth-oidc-client';
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
  { path: 'logged', component: CallbackComponent },
  // Private routes
  {
    path: '',
    component: AppShellComponent,
    canActivate: [autoLoginPartialRoutesGuard],
    children: [
      {
        path: '',
        loadChildren: () =>
          import('./features/reports/reports.routes')
            .then(m => m.REPORTS_ROUTES),
        data: { breadcrumb: 'Reports' }
      }
      {
        path: 'settings',
        loadChildren: () =>
          import('./features/settings/settings.routes')
            .then(m => m.SETTINGS_ROUTES),
        data: { breadcrumb: 'Settings' }
      }
    ],
  },
  // fallback  
  { path: '**', redirectTo: '' },
];

My CallbackComponent is empty on purpose (TS and HTML), but can be used for testing, like:

import { Component, inject, OnInit } from '@angular/core';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Component({
  selector: 'app-callback',
  imports: [],
  standalone: true,
  templateUrl: './callback.component.html',
  styleUrl: './callback.component.scss'
})
export class CallbackComponent implements OnInit {
  oidcSecurityService = inject(OidcSecurityService);

  ngOnInit() {
    console.log('NG ON INIT CALLBACK')
    this.oidcSecurityService.userData$.subscribe(res => {
      console.log(res)
    })

    this.oidcSecurityService.checkAuth().subscribe({
      next: (data) => {
        console.log(data); 
      },
    });
  }

}

yanesteves avatar Aug 28 '25 20:08 yanesteves

I solved it by downgrading the package version.

May I rephrase it to »I reverted to an unaffected version«? Downgrading is not a solution. Instead it pins slowly rotting dependencies and makes it more difficult for npm to satisfy (peer) deps.

onkobu avatar Sep 11 '25 19:09 onkobu