Erratic Signing Out with IdentityServer 4

We have users complaining because they are redirected to the login page of the Identity Server while in the middle of their work (and thus losing their current work). We have endeavoured to configure a sliding expiration, so I'm not sure why this is happening.

I realise there is quite a bit of code in this post. But there are a lot of moving parts and I want to give as much information as possible.

This behavour is arratic and it is hard to report an exact reproducable event. In my testing, I've been ejected at random times and it is hard to understand whether it has any relationship to any of the cofigurations which I have set. In my mind, I should not be ejected at all, as a silent sign-in is always sent during the addAccessTokenExpiring event.

The setup that we have is:

  • an Idp (using IdentityServer 4)
  • A client app, implemented using Vue.js (using Typescript)
  • An API, written in ASP.NET Core 5

The config and auth service which we have written are:
auth.config.ts

import { Log, UserManagerSettings, WebStorageStateStore } from "oidc-client";
import AppConfig from "./invariable/app.config";
/* eslint-disable */
class AuthConfig {
    public settings: UserManagerSettings;
    private baseUrl: string;

    constructor() {
        this.baseUrl = AppConfig.RunTimeConfig.VUE_APP_APPURL || process.env.VUE_APP_APPURL;

        this.settings = {
            userStore: new WebStorageStateStore({ store: window.localStorage }),
            authority: AppConfig.RunTimeConfig.VUE_APP_IDPURL || process.env.VUE_APP_IDPURL,
            client_id: AppConfig.RunTimeConfig.VUE_APP_CLIENTID || process.env.VUE_APP_CLIENTID,
            client_secret: AppConfig.RunTimeConfig.VUE_APP_CLIENTSECRET || process.env.VUE_APP_CLIENTSECRET,
            redirect_uri: this.baseUrl + process.env.VUE_APP_AUTHCALLBACK,
            automaticSilentRenew: false,
            silent_redirect_uri: this.baseUrl + process.env.VUE_APP_SILENTREFRESH,
            response_type: "code",
            response_mode: "query",
            scope: "our_scopes",
            post_logout_redirect_uri: this.baseUrl + process.env.VUE_APP_SIGNOUT_CALLBACK,
            filterProtocolClaims: true,
            loadUserInfo: true,
            revokeAccessTokenOnSignout: true,
            staleStateAge: 300, // should match access_token lifetime.
        };
    }
}
/* eslint-enable */

const authConfig = new AuthConfig();

export default authConfig;

auth.service.ts

import { UserManagerSettings, User, UserManager } from "oidc-client";
import authConfig from "@/config/auth.config";
import axios, { AxiosResponse } from "axios";
import { Ajax } from "@/config/invariable/ajax";
import AccessClaim from "@/domain/general/accessclaim";
import _ from "lodash";
import store from "@/store";
import StoreNamespaces from "@/config/invariable/store.namespaces";
import Token from "@/store/token/token";

export class AuthService {
    private userManager: UserManager;
    private tokenStore: string;

    constructor(private settings: UserManagerSettings) {
        this.settings = settings;
        this.userManager = new UserManager(this.settings);
        this.tokenStore = StoreNamespaces.tokenModule;
    }

    public addEvents(): void {
        this.userManager.events.addUserSignedOut(() => {
            this.signInAgain();
        });

        this.userManager.events.addAccessTokenExpired(() => {
            console.log("Token expired");
            this.clearLocalState();
            console.log("Stale state cleaned up");
        });

        this.userManager.events.addAccessTokenExpiring(() => {
            console.log("Access token about to expire.");
            this.signInAgain();
        });

        this.userManager.events.addSilentRenewError(() => {
            // custom logic here
            console.log("An error happened whilst silently renewing the token.");
        });
    }

    public clearLocalState(): Promise<void> {
        return this.userManager.clearStaleState();
    }

    public getUserOnLoad(): Promise<User> {

        return this.userManager.getUser().then((user) => {
            if (!_.isNil(user) && !user.expired) {

                console.log("first load sign-in");
                const decodedIdToken = user.profile;

                if (!_.isNil(decodedIdToken.store) && !_.isArray(decodedIdToken.store)) decodedIdToken.store = [decodedIdToken.store];
                if (!_.isNil(decodedIdToken.classification) && !_.isArray(decodedIdToken.classification)) decodedIdToken.classification = [decodedIdToken.classification];
                if (!_.isNil(decodedIdToken.location) && !_.isArray(decodedIdToken.location)) decodedIdToken.location = [decodedIdToken.location];
                if (!_.isArray(decodedIdToken.app)) decodedIdToken.app = [decodedIdToken.app];

                const token = new Token();
                token.accessToken = user.access_token;
                token.idToken = user.id_token;
                token.storeClaims = decodedIdToken.store || [];
                token.userType = decodedIdToken.usertype;
                token.isLoggedIn = user && !user.expired;
                token.app = decodedIdToken.app;
                token.userName = decodedIdToken.name ?? "Unknown User";

                store.dispatch(`${this.tokenStore}/setToken`, token);

                return user;
            } else {
                return this.signInAgain();
            }
        });
    }

    public async getUserIfLoggedIn(): Promise<User | null> {
        const currentUser: User | null = await this.userManager.getUser();
        const loggedIn = currentUser !== null && !currentUser.expired;

        return loggedIn ? currentUser : null;
    }

    public async isLoggedIn(): Promise<boolean> {
        const currentUser: User | null = await this.userManager.getUser();

        return currentUser !== null && !currentUser.expired;
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }

    public getAccessToken(): Promise<string> {
        return this.userManager.getUser().then((data: any) => {
            return data.access_token;
        });
    }

    public signInAgain(): Promise<User> {

        return this.userManager
            .signinSilent()
            .then((user) => {

                console.log("silent sign-in");
                const decodedIdToken = user.profile;

                if (!_.isNil(decodedIdToken.store) && !_.isArray(decodedIdToken.store)) decodedIdToken.store = [decodedIdToken.store];
                if (!_.isNil(decodedIdToken.classification) && !_.isArray(decodedIdToken.classification)) decodedIdToken.classification = [decodedIdToken.classification];
                if (!_.isNil(decodedIdToken.location) && !_.isArray(decodedIdToken.location)) decodedIdToken.location = [decodedIdToken.location];
                if (!_.isArray(decodedIdToken.app)) decodedIdToken.app = [decodedIdToken.app];

                const token = new Token();
                token.accessToken = user.access_token;
                token.idToken = user.id_token;
                token.storeClaims = decodedIdToken.store || [];
                token.userType = decodedIdToken.usertype;
                token.isLoggedIn = user && !user.expired;
                token.app = decodedIdToken.app;
                token.userName = decodedIdToken.name ?? "Unknown User";

                store.dispatch(`${this.tokenStore}/setToken`, token);

                return user;
            })
            .catch((err) => {
                console.log("silent error");
                console.log(err);

                this.login();
                return err;
            });
    }

    public getAccessClaims(userDetails: any): Promise<AxiosResponse<any>> {
        return axios.post(`${Ajax.appApiBase}/PermittedUse/GetAccessesForUser`, userDetails).then((resp: AxiosResponse<any>) => {
            return resp.data;
        });
    }

    public getPermissions(userDetails: any, siteId: number | null): Promise<AxiosResponse<any>> {
        return axios.get(`${Ajax.appApiBase}/PermittedUse/GetPermissions/${siteId ?? 0}`).then((resp: AxiosResponse<any>) => {
            return resp.data;
        });
    }

    public constructAccess(userType: string, claims: Array<AccessClaim>): Array<AccessClaim> {
        switch (userType) {
            case "storeadmin":
            case "storeuser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "store";
                });
            case "warduser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "classification";
                });
        }

        return Array<AccessClaim>();
    }

    public getBookableLocations(userType: string, claims: Array<AccessClaim>): Array<AccessClaim> {
        switch (userType) {
            case "storeadmin":
            case "storeuser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "store";
                });
            case "warduser":
                return _.filter(claims, (claim) => {
                    return claim.claim === "location";
                });
        }

        return Array<AccessClaim>();
    }
}

export const authService = new AuthService(authConfig.settings);

On the Idp, our client configuration is:

ClientName = IcClients.Names.ConsumablesApp,
ClientId = IcClients.ConsumablesApp,

RequireConsent = false,
AccessTokenLifetime = TokenConfig.AccessTokenLifetime, // 300 for test purposes
IdentityTokenLifetime = TokenConfig.IdentityTokenLifetime, // 300
AllowOfflineAccess = true,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Sliding,
UpdateAccessTokenClaimsOnRefresh = true,
RequireClientSecret = true,
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,

AllowAccessTokensViaBrowser = true,
AlwaysIncludeUserClaimsInIdToken = true,
RedirectUris = new List<string>
{
    "https://localhost:44336/authcallback.html",
    "https://localhost:8090/authcallback.html",
    "https://localhost:44336/silent-refresh.html",
    "https://localhost:8090/silent-refresh.html"
},
PostLogoutRedirectUris = new List<string>
{
    "https://localhost:44336/signout-callback-oidc.html",
    "https://localhost:8090/signout-callback-oidc.html"
},
AllowedScopes = new List<string>
{
    IdentityServerConstants.StandardScopes.OpenId,
    IdentityServerConstants.StandardScopes.Profile,
    IdentityServerConstants.LocalApi.ScopeName,
    IcAccessScopes.IcAccessClaimsScope,
    IdentityResources.UserDetails,
    IcAccessScopes.ConsumablesScope
},
ClientSecrets = { new Secret("oursecret".Sha256())}

At the Idp, we are using ASP.NET Core Identity:

services.AddIdentity<IdpUser, IdentityRole<int>>()
    .AddEntityFrameworkStores<IdpDbContext>()
    .AddDefaultTokenProviders();

services.ConfigureApplicationCookie(options =>
{
    options.ExpireTimeSpan = cookieDuration; // set to 1 hour
    options.SlidingExpiration = true;
});

My expectation is that the sliding window should be extended every 5 minutes, as the user should be signed in again silently before the token expires.

When monitoring the IDP in my dev environment, one thing which I did note is that the checksession call is only being made once, when the user logs in. The wiki says that the checksession call should happen every 2s (by default). I have not changed this default (not knowingly). I even expressly set the checkSessionInterval property to 2000 to ensure that it was set to 2s.

The other thing I want to set out is the silent refresh html file, as I realise the CSP stuff can play into this:

<head>
  <title></title>
  <meta http-equiv="Content-Security-Policy" content="frame-src 'self' <%= VUE_APP_IDPURL %>; script-src 'self' 'unsafe-inline' 'unsafe-eval';" />
</head>
<body>
  <script src="./oidc-client.min.js"></script>
  <script>
    (function refresh() {
      window.location.hash = decodeURIComponent(window.location.hash);
      new Oidc.UserManager({
        // eslint-disable-next-line @typescript-eslint/camelcase
        response_mode: "query",
        userStore: new Oidc.WebStorageStateStore({
          store: window.localStorage,
        }),
      })
        .signinSilentCallback()
        .then(function() {
          console.log("****************************************signinSilentCallback****************************************");
        })
        .catch(function(err) {
          debug;
          console.log(err);
        });
    })();
  </script>
</body>

If anyone can shed any light on this, it would be much appreciated.



from Recent Questions - Stack Overflow https://ift.tt/2Thupzg
https://ift.tt/eA8V8J

Comments

Popular posts from this blog

Spring Elasticsearch Operations

Network Error and Timeout on Authorize.net JS

Object oriented programming concepts (OOPs)