Skip to content

Setting up a site with mandatory login and basic edge cases is insanely complicated #1146

Open
@lf-novelt

Description

@lf-novelt

Describe the bug

Avoid entering a redirect loop, avoid requests triggered before valid token is available (those will trigger errors client side), handling the refresh_token flow, redirecting user to initial url after login, new tabs share the same credentials than first tab...

All of those seems to be basic features you would like built-in in oauth2 framework like this. However, here how my implementation turned out and I am really disappointed by its lack of simplificity nor existing example that cover all of those.

How my implementation looks like

Read the comments to understand why I did things like this...

oauth.module.ts with useful configs
declare global {
  interface Window { xTenantId: string; xClientId: string } // declared in _Layout.cshml
}

const xTenandId = window.xTenantId;
const xClientId = window.xClientId;

export const authCodeFlowConfig: AuthConfig = {
  // Url of the Identity Provider
  issuer: `https://login.microsoftonline.com/${xTenandId}/v2.0`,

  // URL of the SPA to redirect the user to after login
  redirectUri: location.origin + URL_BASE + '/login-redirect', // unique route to prevent loops

  // The SPA's id. The SPA is registerd with this id at the auth-server
  clientId: xClientId,

  responseType: 'code',

  // set the scope for the permissions the client should request
  // The first four are defined by OIDC.
  // Important: Request offline_access to get a refresh token
  // The api scope is a usecase specific one
  scope: `openid offline_access api://${xClientId}/.default`,

  showDebugInformation: true, //!environment.production,
 
  // turn off validation that discovery document endpoints start with the issuer url defined above because some url starts with sts.windows.net instead of login.microsoftonline.com
  strictDiscoveryDocumentValidation: false,

  postLogoutRedirectUri: location.origin + URL_BASE,

  redirectUriAsPostLogoutRedirectUriFallback: false, // else it redirects to redirectUri ie '/login-redirect' and user stays stuck there

  waitForTokenInMsec: 8000, // set a big number, else it will just trigger HTTP requests without a valid token, and we don't want to trigger requests without token anyway
}


// We need a factory, since localStorage is not available during AOT build time.
export function storageFactory(): OAuthStorage {
  return new myAppLocalStorage(); // else it defaults to sessionStorage, which is not even shared by tab
}

class myAppLocalStorage implements OAuthStorage {
  prefix = 'myapp_'; // to prevent conflict with other apps on same domain
  getItem(key: string): string {
    return localStorage.getItem(this.prefix + key);
  }

  removeItem(key: string): void {
    localStorage.removeItem(this.prefix + key);
  }

  setItem(key: string, data: string): void {
    localStorage.setItem(this.prefix + key, data);
  }
}

@NgModule({
  declarations: [],
  imports: [OAuthModule.forRoot({
    resourceServer: {
      allowedUrls: [API_URL_BASE],
      sendAccessToken: true
    }
  }),],
  providers: [
    { provide: OAuthStorage, useFactory: storageFactory }
  ]
})
export class XOauthModule { }
app.component.ts with most of the logic to know when to redirect to login
  ngOnInit() {

    console.log('AppComponent - ngOnInit');

    // for debugging
    this.oauthSvc.events.subscribe(event => {
      if (event instanceof OAuthErrorEvent) {
        console.error('oauthService event', event);
      } else {
        console.debug('oauthService event', event);
      }
    });
    this.oauthSvc.configure(authCodeFlowConfig);
    this.oauthSvc.setupAutomaticSilentRefresh();

    // Inspiration but not totally enough: Manually Skipping the Login Form -- https://manfredsteyer.github.io/angular-oauth2-oidc/docs/additional-documentation/manually-skipping-login-form.html
    // IMPORTANT: doing the checks in a synchronous way so chikdren components don't trigger unauthorized ajax calls in the meanwhile
    let validToken = this.oauthSvc.hasValidAccessToken();
    let waitForAuth$ = validToken ? of(true) : this.oauthSvc.events.pipe(filter(e => e.type === 'token_received'), map(() => true));

    console.debug('AppComponent - has validToken? ', validToken);
    if (validToken === false) {
      // WARNING prevent login loop, after login, the token can take time to arrive, so we need to check we only redirect to login the first time
      if (!sessionStorage.getItem('LS_REDIRECT_URL')) { // not logged in and not in process of authenticating
        console.debug('invalidToken, not in process of authenticating');
        if (this.oauthSvc.getRefreshToken()) { // check if refresh token is there, if so uses it instead of redirecting
          console.debug('invalidToken, trying to refresh');
          storageFactory().removeItem('access_token'); // clears the expired access_token so it's not used for early ajax requests before the token is refreshed

          this.oauthSvc.loadDiscoveryDocument().then(() => {
            console.debug('AppComponent - loadDiscoveryDocument');
            this.oauthSvc.refreshToken().catch(e => {
              // refresh_token is probably expired, so redirect to login required 
              console.warn('error refreshing token', e);
              this.redirectToLogin();
            });
          });
        } else {
          console.debug('invalidToken, in process of authenticating with LS_REDIRECT_URL=', sessionStorage.getItem('LS_REDIRECT_URL')) 
          this.oauthSvc.loadDiscoveryDocument().then(() => {
            console.debug('AppComponent - loadDiscoveryDocument');
            this.redirectToLogin();
          });
        }
      } else { // still getting token from code flow
        console.debug('AppComponent - still getting token from code flow');
        this.oauthSvc.loadDiscoveryDocumentAndTryLogin().then((hasReceivedTokens) => {
          console.debug('AppComponent - loadDiscoveryDocumentAndTryLogin', hasReceivedTokens);
        });
      }

    } else { // already logged in
      this.oauthSvc.loadDiscoveryDocument().then((hasReceivedTokens) => {
        console.debug('AppComponent - loadDiscoveryDocumentAndTryLogin', hasReceivedTokens);
      });
    }

    waitForAuth$.subscribe(() => {
      let accessToken = this.oauthSvc.getAccessToken();
      console.debug('AppComponent - hasValidAccessToken', accessToken);
      this.webSocketService.initConnection(accessToken); // get accessToken and pass to config.qs
    });
  }

  redirectToLogin() {
    //debugger;
    let redirectUrl = location.href.replace(location.origin + URL_BASE, '');
    console.debug('redirectToLogin with LS_REDIRECT_URL=', redirectUrl);
    sessionStorage.setItem('LS_REDIRECT_URL', redirectUrl); // allows us to know which url the user requested, and also useful to know that we already redirected to login, so we don't enter a login loop
    this.oauthSvc.initLoginFlow();
  }
loginRedirect.component.ts
@Component({
  selector: "app-redirect",
  template: `<pre>Logged in successfully, redirecting to requested url... If it does not work, <button [routerLink]="['/']">Go home</button></pre>`
})
export class AuthRedirectComponent implements OnInit {

  constructor(private router: Router,
    private oauthService: OAuthService) { }

  ngOnInit(): void {
    console.log("AuthRedirectComponent activated, state=" + this.oauthService.state);
    //debugger;

    if (this.oauthService.hasValidAccessToken()) {
      this.redirect();
    } else {
      this.oauthService.events.pipe(filter((e) => e.type === 'token_received'), take(1)).subscribe((e) => {
        this.redirect();
      });
    }
  }

  redirect() {
    const url = sessionStorage.getItem('LS_REDIRECT_URL');
    if (url) {
      sessionStorage.removeItem('LS_REDIRECT_URL');
      this.router.navigateByUrl(decodeURIComponent(url));
    } else
      this.router.navigate(["/"]);
  }

}

Additional context
Hopefully it helps someone who have the same needs, and you can provide feedback it you feel it could be improved somehow.

Maybe adding a sample that covers all of use cases could be useful to others.

Thanks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    docsIssues that involve improving or adding documentation.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions