Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lf-novelt opened this issue Oct 14, 2021 · 8 comments
Labels
docs Issues that involve improving or adding documentation.

Comments

@lf-novelt
Copy link

lf-novelt commented Oct 14, 2021

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.

@jeroenheijmans jeroenheijmans added the docs Issues that involve improving or adding documentation. label Oct 14, 2021
@jeroenheijmans
Copy link
Collaborator

I fully agree, which is why I created a sample myself as well a few years back (and kept it up to date). It's very similar to your choices OP, main difference being I tried to keep it out of the app.component and isolate it in a service. The initial login sequence is annotated in my sample but the gist of it is:

public runInitialLoginSequence(): Promise<void> {
  return this.oauthService.loadDiscoveryDocument()
    .then(() => this.oauthService.tryLogin())
    .then(() => {
      if (this.oauthService.hasValidAccessToken()) {
        return Promise.resolve();
      }

      return this.oauthService.silentRefresh()
        .then(() => Promise.resolve())
        .catch(result => {
          const errorResponsesRequiringUserInteraction = ['interaction_required', 'login_required', 'account_selection_required', 'consent_required'];
          if (result
            && result.reason
            && errorResponsesRequiringUserInteraction.indexOf(result.reason.error) >= 0) {
            return Promise.resolve(); // User interaction is needed to log in, we will wait for the user to manually log in
          }
          return Promise.reject(result);
        });
    })

    .then(() => {
      this.isDoneLoadingSubject$.next(true);

      if (this.oauthService.state && this.oauthService.state !== 'undefined' && this.oauthService.state !== 'null') {
        let stateUrl = this.oauthService.state;
        if (stateUrl.startsWith('/') === false) {
          stateUrl = decodeURIComponent(stateUrl);
        }
        this.router.navigateByUrl(stateUrl);
      }
    })
    .catch(() => this.isDoneLoadingSubject$.next(true));
}

I'm not sure if it can be part of the library, since both our login flows are quite opinionated and possibly even specific to your IDS and flow. So sharing sample usages might be the best we can do?

The available samples are listed at the top of the readme, not sure if you had seen that? It might be useful to add a link to your post as well? Or would you have other ideas on how to improve this repository around this?

@lf-novelt
Copy link
Author

lf-novelt commented Oct 14, 2021

yes, thanks I found your sample (even starred it to find it later, but couldn't :P) 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()

@jeroenheijmans
Copy link
Collaborator

All good and fair points!

Just a small extra observation regarding:

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

Very true! Since I originally created my sample there are now in fact async APP_INITIALIZERs (not there before, I think), and I think I would do the login sequence there. In fact, I do so in some of my production applications.

This only solves some of the issues you mention, but I do think it solves many of the important edge cases.

FWIW.

@sglindme
Copy link

sglindme commented Oct 31, 2021

Hey is this code:

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

in your auth redirect component because you find that if you just have the part in the else block, then your app will (for some reason) send you to the place you want to redirect to, but then instantly go BACK to this redirect component, and then you have to tell it again to redirect? Because that's what happened to me and so I have like the exact thing in my own code.

@lf-novelt
Copy link
Author

lf-novelt commented Nov 1, 2021

this if hasValidToken else waitForToken is there because you don't know whether the token has arrived yet when you enter the authRedirect component, so it handles both case (either already here and will arrive soon)

Your case that you instantly go back to your redirectComponent is probably because of a route guard in your code, my app does not have any logic to redirect to authRedirect, it's only activated via the redirect_uri passed to AD provider.

If it's goes back to the AD provider after that, it's probably because your app does not check if the authentication is still in progress, that's why I used the sessionStorage.getItem("LS_REDIRECT_URL") to know that (ie do not "forceLogin", just tryLogin() if this is already set)

@sglindme
Copy link

sglindme commented Nov 1, 2021

Yea I presume it's the last paragraph happening.

An enum in the library saying the current state of the auth process would be very helpful.

@lf-novelt
Copy link
Author

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

@mmanista-bynd
Copy link

mmanista-bynd commented May 16, 2023

not sure if this is still helpful, but i've done something like this:

/**
   * Configure OAuth and redirect a user to the identity provider if he does not have
   * a valid access token.
   * @returns an empty observable if a redirect needs to take place; otherwise an observable
   * with "true" value
   */
  configureOAuth() {
    this.oauthService.configure({
      ...this.environment.auth,
      strictDiscoveryDocumentValidation: false,
      clearHashAfterLogin: true,
    });
    this.oauthService.tokenValidationHandler = new JwksValidationHandler();
    return fromPromise(this.oauthService.loadDiscoveryDocumentAndTryLogin()).pipe(
      map((value) => {
        if (!this.oauthService.hasValidAccessToken()) {
          this.oauthService.initImplicitFlow();
          return EMPTY;
        }
        return value;
      })
    );
  }

then, the earlier we call this, the better, so as mentioned above, doing this in APP_INITIALIZER might be a good idea:

    {
      provide: APP_INITIALIZER,
      deps: [AuthService],
      useFactory: (authService: AuthService) => {
        return () =>
          /* force a user to log in before initializing the app */
          authService.configureOAuth().pipe(
            tap(() => {
              /* once logged in, you can put the existing APP_INITIALIZER logic here (if you had any)  */
            }),
            take(1)
          );
      },
      multi: true,
    }

Thanks to converting to an observable, that solution can be easily extended by e.g. calling the api to fetch some core data of an app (by e.g. chaining a switchMap) etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Issues that involve improving or adding documentation.
Projects
None yet
Development

No branches or pull requests

4 participants