#StackBounty: #angular #identityserver4 #oidc-client-js OIDC-client Single Log Out cause infinite loop

Bounty: 100

I’ve an Identity Server 4 app with Angular client using oidc-client. The problem is that in production the method addUserSignedOut doesn’t stop to get called after I login in the client. I test it locally and works fine.

This is my IDP code:

 public void ConfigureServices(IServiceCollection services)
    {
        string connectionString = Configuration.GetConnectionString("DefaultConnection");
        var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

        // SSO Cookies
        services.Configure<CookiePolicyOptions>(options =>
        {
            // This lambda determines whether user consent for non-essential cookies is needed for a given request.
            options.CheckConsentNeeded = context => true;
            options.MinimumSameSitePolicy = SameSiteMode.None;
        });

        services.AddDataProtection()
            .AddKeyManagementOptions(dp => dp.NewKeyLifetime = TimeSpan.FromDays(90));
            //.PersistKeysToAzureBlobStorage(new Uri("<blobUriWithSasToken>"))
            //.ProtectKeysWithAzureKeyVault("<keyIdentifier>", "<clientId>", "<clientSecret>");

        services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));

        services.AddIdentity<ApplicationUser, IdentityRole>(options => {
            options.Password.RequiredLength = 6;            // Passwords must be at least 6 characters
            options.Password.RequireLowercase = true;       // Passwords must have at least one lowercase ('a'-'z')
            options.Password.RequireUppercase = true;       // Passwords must have at least one uppercase ('A'-'Z')
            options.Password.RequireDigit = true;           // Passwords must have at least one digit ('0'-'9')
            options.Password.RequireNonAlphanumeric = true; // Passwords must have at least one non alphanumeric character
            options.Password.RequiredUniqueChars = 1;       // Passwords must use at least 1 different characters
        })
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

        services.AddMvc().SetCompatibilityVersion(Microsoft.AspNetCore.Mvc.CompatibilityVersion.Version_2_1).AddXmlSerializerFormatters();

        services.Configure<IISOptions>(iis =>
        {
            iis.AuthenticationDisplayName = "Windows";
            iis.AutomaticAuthentication = false;
        });

        services.AddTransient<IProfileService, ProfileService>();

        services.AddAuthentication().AddCookie("Cookies");

        var builder = services.AddIdentityServer(
            options =>
            {
                options.Events.RaiseErrorEvents = true;
                options.Events.RaiseInformationEvents = true;
                options.Events.RaiseFailureEvents = true;
                options.Events.RaiseSuccessEvents = true;
            }
        )
        // this adds the config data from DB (clients, resources)
        .AddConfigurationStore(options =>
        {
            options.ConfigureDbContext = b =>
                b.UseSqlServer(connectionString,
                    sql => sql.MigrationsAssembly(migrationsAssembly));
        })

        // this adds the operational data from DB (codes, tokens, consents)
        .AddOperationalStore(options =>
        {
            options.ConfigureDbContext = b =>
                b.UseSqlServer(connectionString,
                    sql => sql.MigrationsAssembly(migrationsAssembly));

            // this enables automatic token cleanup. this is optional.
            options.EnableTokenCleanup = true;
        });

        // SSO Use a unique identity cookie name rather than sharing the cookie across applications in the domain.
        services.ConfigureApplicationCookie(options =>
        {
            options.Cookie.Name = "TTIdentityProvider.Identity";
        });

        // Add SAML SSO services.
        services.AddSaml(Configuration.GetSection("SAML"));

        builder.Services.Configure<SecurityStampValidatorOptions>(opts =>
        {
            opts.OnRefreshingPrincipal = SecurityStampValidatorCallback.UpdatePrincipal;
        });

        if (Environment.IsDevelopment() || Environment.EnvironmentName == "local" )
        {
            builder.AddDeveloperSigningCredential();
        }
        else
        {
            X509Certificate2 cert = null;
            using (X509Store certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser))
            {
                certStore.Open(OpenFlags.ReadOnly);
                X509Certificate2Collection certCollection = certStore.Certificates.Find(
                    X509FindType.FindByThumbprint,
                    Configuration.GetSection("Certificate").GetValue<string>("thumbprint"),
                    false);
                // Get the first cert with the thumbprint
                if (certCollection.Count > 0)
                {
                    cert = certCollection[0];
                    Log.Logger.Information($"Successfully loaded cert from registry: {cert.Thumbprint}");
                }
            }

            // Fallback to local file for development
            if (cert == null)
            {
                throw new Exception("Cannot find any Certificate");
            }
            builder.AddSigningCredential(cert);
        }
    }

    public void Configure(IApplicationBuilder app)
    {
        if (Environment.IsDevelopment() || Environment.EnvironmentName == "local")
        {
            app.UseDeveloperExceptionPage();
            app.UseDatabaseErrorPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
            app.UseHsts();//SSO
        }

        app.UseHttpsRedirection();//SSO
        app.UseStaticFiles();
        app.UseCookiePolicy();//SSO
        app.UseIdentityServer();
        app.UseAuthentication();//SSO ?

        app.UseMvcWithDefaultRoute();
    }

IDP logout controller:

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Logout(LogoutInputModel model)
    {
        // build a model so the logged out page knows what to display
        var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);

        if (User?.Identity.IsAuthenticated == true)
        {
            // Request logout at the service provider(SAML).
            await InitiateSingleLogout();

            // delete local authentication cookie
            await _signInManager.SignOutAsync();

            // raise the logout event
            await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
        }

        if (vm.PostLogoutRedirectUri != null) {
            return Redirect(vm.PostLogoutRedirectUri);
        }

         // since we don't have a valid context, then we just go back to the home page
        return Redirect("~/");

    }

SAML logout:

    private async Task InitiateSingleLogout()
    {
        var ssoState = await _samlIdentityProvider.GetStatusAsync();
        if (await ssoState.CanSloAsync())
        {
            // Request logout at the service provider(s).
            await _samlIdentityProvider.InitiateSloAsync();
        }
     }

Angular client oidc-client settings:

const settings: any = {
  authority: `${environment.identity_server_url}`,
  client_id: 'js',
  redirect_uri: `${environment.login_redirect}/signin-oidc`,
  response_type: 'id_token token',
  scope: 'openid profile salesforce api1',
  post_logout_redirect_uri: `${environment.login_redirect}`,
  userStore: new WebStorageStateStore({ store: window.localStorage }),
  silent_redirect_uri: `${environment.login_redirect}/signin-oidc-silent`,
  // automaticSilentRenew: true
};

Handle logout event:

 this.mgr.events.addUserSignedOut(() => {
  this.startSigninMainWindow({ external_logout: true })
 })

This is my guard where I check if the user is Logged in and call signinRedirect():

export class AuthGuardService implements CanActivate {

  constructor(
    private authService: AuthService
  ) { }
  canActivate() {
    const isLoggedIn = this.authService.isLoggedInObs();
    isLoggedIn.subscribe(loggedin => {
      if (!loggedin) {
        this.authService.startSigninMainWindow();
      }
    });
    return isLoggedIn;

  }

}

This is the AuthService class where all the logic about the oidc-client lie.

export class AuthService {
  mgr: UserManager = new UserManager(settings);
  userLoadededEvent: EventEmitter<User> = new EventEmitter<User>();
  currentUser: User;
  loggedIn = false;
  authHeaders: Headers;

  private accountID: string;
  private accountToken: string;
  private internInfoSubject = new BehaviorSubject<InternUser>(null);
  public readonly internInfo$ = this.internInfoSubject
    .asObservable()
    .pipe(
      filter(user => user !== null),
      distinctUntilChanged()
      );

  constructor(
    private router: Router,
    private internPortalService: InternPortalService,
    private apiService: ApiService,
    private http: HttpClient
  ) {

    this.mgr.events.addUserSignedOut(() => {
      this.startSigninMainWindow({ external_logout: true })
    })
  }

  getUser() {
    this.mgr
      .getUser()
      .then(user => {
        console.log('got user', user);
        this.currentUser = user;
        this.userLoadededEvent.emit(user);
      })
      .catch(function(err) {
        console.log(err);
      });
  }

  startSigninMainWindow(query = {}) {
    this.mgr.signinRedirect({extraQueryParams: query});
  }

  endSigninMainWindow() {
    this.mgr
      .signinRedirectCallback()
      .then(user => {
        console.log('signed in', user);
        this.currentUser = user;
        this.setLocalStorage();
      })
      .catch(function(err) {
        console.log(err);
      });
  }

  endSigninSilentMainWindow() {
    this.mgr
      .signinSilentCallback()
      .then(() => {
        console.log('end silent signed in');
      })
      .catch(function(err) {
        console.log(err);
      });
  }

  changePassword(): Observable<any> {
    const params = new HttpParams()
      .set('userId', this.currentUser.profile.sub)
      .set('passwordAction', '2')
      .set('redirectUrl', window.location.href);
    return this.apiService.getIdentityServer('/Token', params);
  }

  startSignoutMainWindow() {
    this.mgr
      .signoutRedirect()
      .then(function(resp) {
        console.log('signed out', resp);
        // setTimeout(5000, () => {
        //   console.log('testing to see if fired...');

        // });
      })
      .catch(function(err) {
        console.log(err);
      });
  }

  isLoggedInObs(): Observable<boolean> {
    return observableFrom(this.mgr.getUser()).pipe(
      map<User, boolean>(user => {
        if (user) {
          if (!this.internPortalService.getAccountId()) {
            this.currentUser = user;
            this.setLocalStorage();
          }
          return true;
        } else {
          return false;
        }
      })
    );
  }

  onSignOut(callback: Function){
    this.mgr.events.addUserSignedOut(resp => {
      console.log("user signed out");
      callback();
    });
    }

  get authenticationInfo(): InternUser {
    return this.internInfoSubject.value;
  }

  private setLocalStorage() {
    this.accountID = this.currentUser.profile.account_id;
    this.accountToken = this.currentUser.access_token;
    this.internPortalService.setAccountId(this.accountID, this.accountToken);
    this.internPortalService
      .getIntern()
      .subscribe(res => this.updateToken(res));
  }

  updateToken(internUser: any): void {
    console.log(internUser);
    if (!internUser) {
      return;
    }
    // TODO: Refactor once BE migration is completed
    internUser.auth = this.currentUser;
    internUser.state_locales = this.internPortalService.getStateLocales(
      internUser.state
    );
    this.internInfoSubject.next(internUser);
    this.router.navigate(['/intern-portal']);
  }

  // TODO: To be removed after QA
  public updateLocale(counter: number): void {
    const stateList = ['TX', 'MI', 'AZ', 'NC', 'SC', 'FL', 'NV', 'IN'];
    const newUser = this.authenticationInfo;
    newUser.state = stateList[counter];
    newUser.state_locales = this.internPortalService.getStateLocales(
      newUser.state
    );
    console.log(`An user from: ${newUser.state_locales.state} was loaded`);
    this.internInfoSubject.next(newUser);
  }

  logout(): void {
    localStorage.clear();
    this.internInfoSubject.next(null);
  }
}

And the signin-oidc-silent component:

export class SigninOidcComponent implements OnInit {
  private element: any;

  constructor(private authService: AuthService) {
  }

  ngOnInit() {
    console.log('init oidc logic');
    this.authService.endSigninMainWindow();
  }

}

I just don’t know why the infinite loop happens on the hosted apps. Actually if I use the client locally and point to my hosted IDP I get the same infinite loop.

I don’t see any error on console, only this warning ResponseValidator._processSigninParams: Response was error login_required


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.