question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

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

See original GitHub issue

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.

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:4
  • Comments:7

github_iconTop GitHub Comments

3reactions
lf-noveltcommented, Oct 14, 2021

yes, thanks I found your sample (even starred it to find it later, but couldn’t 😛) but found it way too complex to integrate into my app, and also I don’t required route guards, but after all the things I ended up doing, I might havae as well used your auth.service.ts.

And even with that, your runInitialLoginSequence is asynchronous so if unguarded components just do some HTTP requests in their ngOnOnit, those requests won’t have a valid token and will fail. (that’s where adding a guard to every component could make sense, but is very cumbersome)

I used to use a server side guard before doing it client side, and it was much easier to handle…

IMO we need to warn users about those timings issues that are not really documented and can drive you crazy:

  • how do I know I was already redirected and the token is currently being retrieved (this is not instant!!)? maybe add a boolean property or an observable in the service
  • set the waitForTokenInMs default value to > 0, something like 1000 would make sense
  • in the interceptor, check that the token is still valid instead of blindly using it -> here
  • have a way to clear invalid accessToken without logout. if (svc.getAccessToen() && svc.hasValidAccessToken() == false)) svc.destroyAccessToken()
0reactions
lf-noveltcommented, Nov 1, 2021

I think you need to use the sessionStorage at the some point because of the redirect, you lose the JS context.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Edge Cases Are Real and They're Hurting Your Users - Modus
In our photo upload scenario, a contextual edge case might involve the user uploading a photo that is offensive or pornographic, or uploading...
Read more >
Patterns for authentication at the edge | Fastly
Validating a user's identity and access rights is something that is in the critical performance path, required site-wide, ...
Read more >
What is an 'edge case' when programming? - Quora
“Edge cases” are also known as “corner cases” and occur usually at some “limit” in your code at which the code can malfunction...
Read more >
Expecting the Unexpected: Edge Cases in Web Design
The classic example of edge cases in web UX is accessibility. Unless your content is of low value, it's critical to ensure that...
Read more >
What are edge cases and corner cases | Testing for them - Ignys
How to test for edge cases during product development. Edge case questions and ... These corner cases can create some very weird effects....
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found