akveo / nebular

:boom: Customizable Angular UI Library based on Eva Design System :new_moon_with_face::sparkles:Dark Mode
https://akveo.github.io/nebular
MIT License
8.05k stars 1.51k forks source link

Auth callback continually redirecting #541

Closed jjgriff93 closed 5 years ago

jjgriff93 commented 6 years ago

Issue type

I'm submitting a ... (check one with "x")

Issue description

Current behavior: I've followed the documentation for setting up OAuth authentication (which I've done in the ngx-admin starter app) - instead of Google however I'm setting up Azure AD B2C authentication.

The login request works fine, it goes to Microsoft's auth servers and then returns with an id_token in the callback url. However, when the callback happens and the router directs to the nb-oauth2-callback.component.ts, the 'Authenticating...' message shows up briefly, and then instead of redirecting to the homescreen as I've defined it to redirect to (as per the docs), it instead goes through the login process again, repeating the process in a never-ending loop.

The url of the callback from the Microsoft auth server looks like this: https://localhost:5001/auth/callback#id_token=eyJ0eXAiOiJKV1Q-REST-OF-TOKEN

I've tried to do some debugging, and have noticed a couple of things when going through the code -

There's an exception in the token-parceler.js file: Cannot read property 'name' of null at NbAuthTokenParceler.push...

And a 'no match' exception in the router.js file with the url parameter in 23. this.Recognizer showing as 'auth/callback#id_tokeneyJ0eX-REST-OF-TOKEN'.

Not sure if this is relevant to the redirect looping issue however seems like the token isn't being recognised/intercepted from the url header and that the router is treating it as a url fragment.

Really appreciate any help in figuring this out!

Expected behavior:

Steps to reproduce: In the ngx-admin starter app, I've created a nb-oauth2-login.component.ts like so:

import { Component, OnDestroy } from '@angular/core';
import { NbAuthOAuth2Token, NbAuthResult, NbAuthService } from '@nebular/auth';
import { takeWhile } from 'rxjs/operators';

@Component({
  selector: 'ngx-oauth2-login',
  template: `
  <nb-layout>
    <nb-layout-column>
      <nb-card>
        <nb-card-body>
          <p>Current User Authenticated: {{ !!token }}</p>
          <p>Current User Token: {{ token|json }}</p>
          <button class="btn btn-success" *ngIf="!token" (click)="login()">Sign In</button>
          <button class="btn btn-warning" *ngIf="token" (click)="logout()">Sign Out</button>
        </nb-card-body>
      </nb-card>
    </nb-layout-column>
  </nb-layout>
  `,
})
export class NbOAuth2LoginComponent implements OnDestroy {

  alive = true;
  token: NbAuthOAuth2Token;

  constructor(private authService: NbAuthService) {
    this.authService.onTokenChange()
      .pipe(takeWhile(() => this.alive))
      .subscribe((token: NbAuthOAuth2Token) => {
        this.token = null;
        if (token && token.isValid()) {
          this.token = token;
        }
      });
  }

  login() {
    this.authService.authenticate('AzureADB2C')
      .pipe(takeWhile(() => this.alive))
      .subscribe((authResult: NbAuthResult) => {
      });
  }

  logout() {
    this.authService.logout('AzureADB2C')
      .pipe(takeWhile(() => this.alive))
      .subscribe((authResult: NbAuthResult) => {
      });
  }

  ngOnDestroy(): void {
    this.alive = false;
  }
}

And a nb-oauth2-callback.component.ts like so:

import { Component, OnDestroy } from '@angular/core';
import { NbAuthResult, NbAuthService } from '@nebular/auth';
import { Router } from '@angular/router';
import { takeWhile } from 'rxjs/operators';

@Component({
  selector: 'ngx-oauth2-callback',
  template: `
      Authenticating...
  `,
})
export class NbOAuth2CallbackComponent implements OnDestroy {

  alive = true;

  constructor(private authService: NbAuthService, private router: Router) {
    this.authService.authenticate('AzureADB2C')
      .pipe(takeWhile(() => this.alive))
      .subscribe((authResult: NbAuthResult) => {
        if (authResult.isSuccess() && authResult.getRedirect()) {
          this.router.navigateByUrl(authResult.getRedirect());
        }
      });
  }

  ngOnDestroy(): void {
    this.alive = false;
  }
}

Set up my core.module.ts file with an authentication strategy like so:

export const NB_CORE_PROVIDERS = [
  ...DataModule.forRoot().providers,
  ...NbAuthModule.forRoot({
    strategies: [
      NbOAuth2AuthStrategy.setup({
        name: 'AzureADB2C',
        clientId: '************************************',
        clientSecret: '',
        authorize: {
          // tslint:disable-next-line:max-line-length
          endpoint: 'https://login.microsoftonline.com/tfp/pixeldr.onmicrosoft.com/b2c_1_pxdrsignin/oauth2/v2.0/authorize',
          responseType: NbOAuth2ResponseType.TOKEN, // have changed in the nebular/auth package (oauth2-strategy.options.js) from 'token' to 'id_token',
          scope: 'https://PixelDr.onmicrosoft.com/api/profile openid profile',
          redirectUri: 'https://localhost:5001/auth/callback',
        },
        token: {
          endpoint: 'id_token',
          class: NbAuthJWTToken, // changed from NbAuthOAuth2Token as ADB2C returns JWT
        },
        redirect: {
          success: '/pages/overview',
        },
      }),
    ],
    forms: {
      login: {
        socialLinks: socialLinks,
      },
      register: {
        socialLinks: socialLinks,
      },
    },
  }).providers,

And finally modified the app-routing.module.ts like so:

const routes: Routes = [
  { path: 'pages', loadChildren: 'app/pages/pages.module#PagesModule' },
  {
    path: 'auth',
    component: NbAuthComponent,
    children: [
      {
        path: '',
        component: NbOAuth2LoginComponent,
      },
      {
        path: 'login',
        component: NbLoginComponent,
      },
      {
        path: 'register',
        component: NbRegisterComponent,
      },
      {
        path: 'logout',
        component: NbLogoutComponent,
      },
      {
        path: 'request-password',
        component: NbRequestPasswordComponent,
      },
      {
        path: 'reset-password',
        component: NbResetPasswordComponent,
      },
      {
        path: 'callback',
        component: NbOAuth2CallbackComponent,
      },
    ],
  },
  { path: '', redirectTo: 'pages', pathMatch: 'full' },
  { path: '**', redirectTo: 'pages' },
];

const config: ExtraOptions = {
  useHash: false,
};

This is all using the latest Nebular release.

Thanks again in advance for any help!

nnixaa commented 6 years ago

Hey @jjgriff93, I believe this is the same (almost) issue as with the https://github.com/akveo/ngx-admin/issues/1757. The first stop is the isRedirectResult method, which is trying to determine whether the request is a redirect request or not. The decision is based on the presence of particular parameters in the callback url, such as access_token or error (https://github.com/akveo/nebular/blob/master/src/framework/auth/strategies/oauth2/oauth2-strategy.ts#L144). In your case both are not presented, thus the request is not treated as a callback and the strategy starts the authentication process all over again.

I would suggest creating a new strategy inherited from this one and re-defining a couple of checks:

As for the error - it is hard to tell the reason. I would suggest adopting the strategy first, that may be a reason for the errors in the first place.

jjgriff93 commented 6 years ago

Hi @nnixaa , thanks very much for getting back to me and for your suggestions. I've tried to implement what you suggest but have a few validation errors cropping up (likely due to my inexperience with Angular!). I've created a class in core.module.ts that extends NbOAuth2AuthStrategy and redefined the redirectResults and getValue methods like so:

export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {
  protected redirectResults = {
    [NbOAuth2ResponseType.TOKEN]: () => {
      return observableOf(this.route.snapshot.fragment).pipe(
        map(fragment => this.parseHashAsQueryParams(fragment)),
        map((params: any) => !!(params && (params.id_token || params.error))),
      );
    },
  };

  getValue(): string {
    return this.token.id_token;
  }
}

I'm getting an error on redirectResults saying Property 'redirectResults' in type 'NbAzureADB2CAuthStrategy' is not assignable to the same property in base type 'NbOAuth2AuthStrategy'. I'm getting another error on route that says Property 'route' is private and only accessible within class 'NbOAuth2AuthStrategy'. and a last error on token saying Property 'token' does not exist on type 'NbAzureADB2CAuthStrategy'.

What am I doing wrong here? Really appreciate your help.

Thanks again!

nnixaa commented 6 years ago

Hey @jjgriff93, you should have something like this:

export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  protected redirectResults = {
    [NbOAuth2ResponseType.CODE]: this.redirectResults[NbOAuth2ResponseType.CODE],

    [NbOAuth2ResponseType.TOKEN]: () => {
      return observableOf(this.activatedRoute.snapshot.fragment).pipe(
        map(fragment => this.parseHashAsQueryParams(fragment)),
        map((params: any) => !!(params && (params.id_token || params.error))),
      );
    },
  };

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              window: any) {
    super(http, activatedRoute, window);
  }
}

Also please make sure you marked NbAzureADB2CAuthStrategy as @Injecteble() and registered it within providers: [] array of your module. At the same time the token class is not a service in Angular terms and there is no need to register it, just pass that new class name into the strategy configuration { token: { class: NbAuthAzureToken }}

Let me know how it goes.

jjgriff93 commented 6 years ago

Thanks @nnixaa, really appreciate your help. I've done as you suggested above and that's resolved all the validation errors (thank you!) - just getting a js console error now on compilation:

Uncaught Error: Can't resolve all parameters for NbAzureADB2CAuthStrategy: ([object Object], [object Object], ?). at syntaxError (compiler.js:215) at CompileMetadataResolver.push../node_modules/@angular/compiler/fesm5/compiler.js.CompileMetadataResolver._getDependenciesMetadata (compiler.js:10810)

My core.module.ts is looking like this (with the top portion & imports cut out):

// Create new token for Azure auth so it returns id_token instead of access_token
export class NbAuthAzureToken extends NbAuthOAuth2Token {
  getValue(): string {
    return this.token.id_token;
  }
}

@Injectable()
export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  protected redirectResults = {
    [NbOAuth2ResponseType.CODE]: this.redirectResults[NbOAuth2ResponseType.CODE],

    [NbOAuth2ResponseType.TOKEN]: () => {
      return observableOf(this.activatedRoute.snapshot.fragment).pipe(
        map(fragment => this.parseHashAsQueryParams(fragment)),
        map((params: any) => !!(params && (params.id_token || params.error))),
      );
    },
  };

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              window: any) {
    super(http, activatedRoute, window);
  }
}

export const NB_CORE_PROVIDERS = [
  ...DataModule.forRoot().providers,
  ...NbAuthModule.forRoot({
    strategies: [
      NbAzureADB2CAuthStrategy.setup({
        name: 'AzureADB2C',
        clientId: '*********************************',
        clientSecret: '',
        authorize: {
          // tslint:disable-next-line:max-line-length
          endpoint: 'https://login.microsoftonline.com/tfp/********************/B2C_signin/oauth2/v2.0/authorize',
          responseType: NbOAuth2ResponseType.TOKEN, // have overloaded this to return id_token instead of token for Azure auth
          scope: 'https://********.onmicrosoft.com/api/profile openid profile',
          redirectUri: 'https://localhost:5001/auth/callback',
        },
        token: {
          endpoint: 'token', // reverted from id_token
          class: NbAuthAzureToken, // changed from NbAuthOAuth2Token
        },
        redirect: {
          success: '/dashboard/overview',
        },
      }),
    ],
    forms: {
      login: {
        socialLinks: socialLinks,
      },
      register: {
        socialLinks: socialLinks,
      },
    },
  }).providers,

  NbSecurityModule.forRoot({
    accessControl: {
      guest: {
        view: '*',
      },
      user: {
        parent: 'guest',
        create: '*',
        edit: '*',
        remove: '*',
      },
    },
  }).providers,
  {
    provide: NbRoleProvider, useClass: NbSimpleRoleProvider,
  },
  AppMonitoringService,
];

@NgModule({
  imports: [
    CommonModule,
  ],
  exports: [
    NbAuthModule,
  ],
  declarations: [],
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    throwIfAlreadyLoaded(parentModule, 'CoreModule');
  }

  static forRoot(): ModuleWithProviders {
    return <ModuleWithProviders>{
      ngModule: CoreModule,
      providers: [
        ...NB_CORE_PROVIDERS,
        NbAzureADB2CAuthStrategy,
      ],
    };
  }
}

Any ideas? Probably something small I've missed. Feel like it's close now! :)

Thanks again

nnixaa commented 6 years ago

Right, my example is missing the correct inject of the window property. Your constructor should be like this:

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              @Inject(NB_WINDOW) window: any) {
    super(http, activatedRoute, window);
  }

This should fix the error.

jjgriff93 commented 6 years ago

Hey @nnixaa, that's fixed the error thank you, so now all compiles ok - however unfortunately the original problem has returned, it's still continually redirecting after trying to authenticate, still doesn't seem to be picking up the token out of the callback URL payload and converting it into a token object. It gets to the callback component, then after a second or two goes back to another authentication attempt.

Any ideas?

Full core.module.ts code now looks like this:

import { ModuleWithProviders, NgModule, Optional, SkipSelf, Injectable, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NbOAuth2AuthStrategy, NbAuthModule, NbOAuth2ResponseType, NbAuthOAuth2Token } from '@nebular/auth';
import { NbSecurityModule, NbRoleProvider } from '@nebular/security';
import { of as observableOf } from 'rxjs';

import { throwIfAlreadyLoaded } from './module-import-guard';
import { DataModule } from './data/data.module';
import { AppMonitoringService } from './monitoring/app-monitoring.service';
import { map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { NB_WINDOW } from '@nebular/theme';

const socialLinks = [
  {
    url: 'https://www.facebook.com/',
    target: '_blank',
    icon: 'socicon-facebook',
  },
];

export class NbSimpleRoleProvider extends NbRoleProvider {
  getRole() {
    // here you could provide any role based on any auth flow
    return observableOf('guest');
  }
}

// Override 'token' to 'id_token' for Azure AD B2C
(NbOAuth2ResponseType as any)['TOKEN'] = 'id_token';

// Create new token for Azure auth so it returns id_token instead of access_token
export class NbAuthAzureToken extends NbAuthOAuth2Token {
  getValue(): string {
    return this.token.id_token;
  }
}

@Injectable()
export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  protected redirectResults = {
    [NbOAuth2ResponseType.CODE]: this.redirectResults[NbOAuth2ResponseType.CODE],

    [NbOAuth2ResponseType.TOKEN]: () => {
      return observableOf(this.activatedRoute.snapshot.fragment).pipe(
        map(fragment => this.parseHashAsQueryParams(fragment)),
        map((params: any) => !!(params && (params.id_token || params.error))),
      );
    },
  };

  constructor(http: HttpClient,
    private activatedRoute: ActivatedRoute,
    @Inject(NB_WINDOW) window: any) {
      super(http, activatedRoute, window);
    };
}

export const NB_CORE_PROVIDERS = [
  ...DataModule.forRoot().providers,
  ...NbAuthModule.forRoot({
    strategies: [
      NbAzureADB2CAuthStrategy.setup({
        name: 'AzureADB2C',
        clientId: '************************************',
        clientSecret: '',
        authorize: {
          // tslint:disable-next-line:max-line-length
          endpoint: 'https://login.microsoftonline.com/tfp/*******.onmicrosoft.com/******/oauth2/v2.0/authorize',
          responseType: NbOAuth2ResponseType.TOKEN, // have overloaded this to return id_token instead of token for Azure auth
          scope: 'https://******.onmicrosoft.com/api/profile  openid profile',
          redirectUri: 'https://localhost:5001/auth/callback',
        },
        token: {
          class: NbAuthAzureToken, 
        },
        redirect: {
          success: '/dashboard/overview',
        },
      }),
    ],
    forms: {
      login: {
        socialLinks: socialLinks,
      },
      register: {
        socialLinks: socialLinks,
      },
    },
  }).providers,

  NbSecurityModule.forRoot({
    accessControl: {
      guest: {
        view: '*',
      },
      user: {
        parent: 'guest',
        create: '*',
        edit: '*',
        remove: '*',
      },
    },
  }).providers,
  {
    provide: NbRoleProvider, useClass: NbSimpleRoleProvider,
  },
  AppMonitoringService,
];

@NgModule({
  imports: [
    CommonModule,
  ],
  exports: [
    NbAuthModule,
  ],
  declarations: [],
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    throwIfAlreadyLoaded(parentModule, 'CoreModule');
  }

  static forRoot(): ModuleWithProviders {
    return <ModuleWithProviders>{
      ngModule: CoreModule,
      providers: [
        ...NB_CORE_PROVIDERS,
        NbAzureADB2CAuthStrategy,
      ],
    };
  }
}
nnixaa commented 6 years ago

Hey @jjgriff93, hard to tell, have you tried to debug it? For example, what would be the result if you print it from this line https://github.com/akveo/nebular/blob/2.0.0-rc.9/src/framework/auth/strategies/oauth2/oauth2-strategy.ts#L161?

jjgriff93 commented 6 years ago

Hi @nnixaa - yes, have stepped through what happens during the auth circling and have done so with the breakpoint you've suggested - the local variable result is false which explains why it's continually retrying. The variables when the breakpoint is hit are as follows:

1. Local
    1. result:false
    2. this:SwitchMapSubscriber
2. Closure 
3. (push../node_modules/@nebular/auth/strategies/oauth2/oauth2-strategy.js.NbOAuth2AuthStrategy.authenticate)
    1. _this:NbOAuth2AuthStrategy
        1. defaultOptions:NbOAuth2AuthStrategyOptions
            1. authorize:
                1. endpoint:"authorize"
                2. responseType:"code"
                3. __proto__:Object
            2. baseEndpoint:""
            3. clientId:""
            4. clientSecret:""
            5. defaultErrors:["Something went wrong, please try again."]
            6. defaultMessages:["You have been successfully authenticated."]
            7. redirect:{success: "/", failure: null}
            8. refresh:{endpoint: "token", grantType: "refresh_token"}
            9. token:{endpoint: "token", grantType: "authorization_code", class: ƒ}
            10. __proto__:Object
        2. http:HttpClient {handler: HttpInterceptingHandler}
        3. options:
            1. authorize:
                1. endpoint:"https://login.microsoftonline.com/tfp/pixeldr.onmicrosoft.com/b2c_1_pxdrsignin/oauth2/v2.0/authorize"
                2. redirectUri:"https://localhost:5001/auth/callback"
                3. responseType:"id_token"
                4. scope:"https://PixelDr.onmicrosoft.com/api/profile openid profile"
                5. __proto__:Object
            2. baseEndpoint:""
            3. clientId:"7f1d19f9-80ed-43c8-86df-ed78b2bd6275"
            4. clientSecret:""
            5. defaultErrors:["Something went wrong, please try again."]
            6. defaultMessages:["You have been successfully authenticated."]
            7. name:"AzureADB2C"
            8. redirect:{success: "/dashboard/overview", failure: null}
            9. refresh:{endpoint: "token", grantType: "refresh_token"}
            10. token:{endpoint: "token", grantType: "authorization_code", class: ƒ}
            11. __proto__:Object
        4. redirectResultHandlers:
            1. code:ƒ ()
            2. id_token:ƒ ()
            3. __proto__:Object
        5. redirectResults:{code: ƒ, id_token: ƒ}
        6. responseType:(...)
        7. route:ActivatedRoute
            1. children:(...)
            2. component:ƒ AppComponent(appMonitoringService)
            3. data:BehaviorSubject {_isScalar: false, observers: Array(0), closed: false, isStopped: false, hasError: false, …}
            4. firstChild:(...)
            5. fragment:BehaviorSubject
                1. closed:false
                2. hasError:false
                3. isStopped:false
                4. observers:[]
                5. thrownError:null
                6. value:(...)
                7. _isScalar:false
                8. _value:"id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1MzE2ODY1NjMsIm5iZiI6MTUzMTY4Mjk2MywidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5taWNyb3NvZnRvbmxpbmUuY29tL2M0YWI4YzIxLWRhODItNDI5NS05ODMyLTViODBhZDllM2JhYi92Mi4wLyIsInN1YiI6ImFlZDQ3NmVkLTc3OTAtNDVhOS05MDkyLTQzN2RlZjIzNTkzYSIsImF1ZCI6IjdmMWQxOWY5LTgwZWQtNDNjOC04NmRmLWVkNzhiMmJkNjI3NSIsImlhdCI6MTUzMTY4Mjk2MywiYXV0aF90aW1lIjoxNTMxNjgyOTYzLCJvaWQiOiJhZWQ0NzZlZC03NzkwLTQ1YTktOTA5Mi00MzdkZWYyMzU5M2EiLCJnaXZlbl9uYW1lIjoiSmFtZWQiLCJmYW1pbHlfbmFtZSI6IkdyaWZmIiwibmFtZSI6IkdyaW1ibGU2OSIsImVtYWlscyI6WyJqamdyaWZmOTNAaG90bWFpbC5jby51ayJdLCJ0ZnAiOiJiMmNfMV9weGRyc2lnbmluIn0.Mn9E2vHuQT6ga_yJ7OwoX91Vxe_rGJ4cRmNFnC6YzE477Qy2JjvntmTysttt8-0IF4xHTYnqG-xoJQ3JpiK7gxTJLePwasIJJpDxRN4NJfGQY4b6qPAR58XVDX0RlPiK40ApOhxdu6q91zcSIdzAjunfj-n7O5vNpXGPCBY_CdyYz4ueDooWT2Bgq6VO3V9jjOsWwi260fLvJ3JNndoMwcyrN5w1zVBMolXYnwe7VzVSs8Qztdo09b9CBHBStwj0yUMAmlwDMMCylGed6-UeHS2P_-Cmmqk6rtw6yTzd7-TSpjj_HuWlhmWTSip8FPMOlZma1dVmQpFpwh782dG1eA"
                9. __proto__:Subject
            6. outlet:"primary"
            7. paramMap:(...)
            8. params:BehaviorSubject {_isScalar: false, observers: Array(0), closed: false, isStopped: false, hasError: false, …}
            9. parent:(...)
            10. pathFromRoot:(...)
            11. queryParamMap:(...)
            12. queryParams:BehaviorSubject {_isScalar: false, observers: Array(0), closed: false, isStopped: false, hasError: false, …}
            13. root:(...)
            14. routeConfig:(...)
            15. snapshot:ActivatedRouteSnapshot {url: Array(0), params: {…}, queryParams: {…}, fragment: "id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZ…Tzd7-TSpjj_HuWlhmWTSip8FPMOlZma1dVmQpFpwh782dG1eA", data: {…}, …}
            16. url:BehaviorSubject {_isScalar: false, observers: Array(0), closed: false, isStopped: false, hasError: false, …}
            17. _futureSnapshot:ActivatedRouteSnapshot {url: Array(0), params: {…}, queryParams: {…}, fragment: "id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZ…Tzd7-TSpjj_HuWlhmWTSip8FPMOlZma1dVmQpFpwh782dG1eA", data: {…}, …}
            18. _routerState:RouterState {_root: TreeNode, snapshot: RouterStateSnapshot}
            19. __proto__:Object
        8. window:Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}
__proto__:NbAuthStrategy

It looks like the token endpoint is still showing as token and not id_token, could this be what's causing it to not pick up the token properly? If so how would I go about resolving this?

Thanks again for your help and sorry for the delay!

nnixaa commented 6 years ago

@jjgriff93 here you go:

// Override 'token' to 'id_token' for Azure AD B2C
(NbOAuth2ResponseType as any)['TOKEN'] = 'id_token';

// Create new token for Azure auth so it returns id_token instead of access_token
export class NbAuthAzureToken extends NbAuthOAuth2Token {

  // let's rename it to exclude name clashes
  static NAME = 'nb:auth:azure:token';

  getValue(): string {
    return this.token.id_token;
  }
}

@Injectable()
export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  // we need this methos for strategy setup
  static setup(options: NbOAuth2AuthStrategyOptions): [NbAuthStrategyClass, NbOAuth2AuthStrategyOptions] {
    return [NbAzureADB2CAuthStrategy, options];
  }

  protected redirectResults: any = {
    [NbOAuth2ResponseType.CODE]: () => observableOf(null),

    [NbOAuth2ResponseType.TOKEN]: () => {

      // queryParams here as token returned not as hash
      return observableOf(this.activatedRoute.snapshot.queryParams).pipe(
        map((params: any) => {
          return !!(params && (params.id_token || params.error));
        }),
      );
    },
  };

  protected redirectResultHandlers = {
    [NbOAuth2ResponseType.CODE]: () => observableOf(null),
    [NbOAuth2ResponseType.TOKEN]: () => {

      // same here, token isn't in hash so have to redefine this to work with queryParams
      return observableOf(this.activatedRoute.snapshot.queryParams).pipe(
        map((params: any) => {
          if (!params.error) {
            return new NbAuthResult(
              true,
              params,
              this.getOption('redirect.success'),
              [],
              this.getOption('defaultMessages'),
              this.createToken(params));
          }

          return new NbAuthResult(
            false,
            params,
            this.getOption('redirect.failure'),
            this.getOption('defaultErrors'),
            [],
          );
        }),
      );
    },
  };

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              @Inject(NB_WINDOW) window: any) {
    super(http, activatedRoute, window);
  }
}

A couple of re-doings and now it is working. I added a couple of comments with the changes.

jjgriff93 commented 6 years ago

Hey @nnixaa , thanks very much for putting this together - took me a while to test as I've been away for a few days. Unfortunately still getting result: false returned after implementing the above in the code, not sure why.

Here's the full .ts file with your modified section included:

import { ModuleWithProviders, NgModule, Optional, SkipSelf, Injectable, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// tslint:disable-next-line:max-line-length
import { NbOAuth2AuthStrategy, NbAuthModule, NbOAuth2ResponseType, NbAuthOAuth2Token, NbAuthResult, NbOAuth2AuthStrategyOptions, NbAuthStrategyClass } from '@nebular/auth';
import { NbSecurityModule, NbRoleProvider } from '@nebular/security';
import { of as observableOf } from 'rxjs';

import { throwIfAlreadyLoaded } from './module-import-guard';
import { DataModule } from './data/data.module';
import { AppMonitoringService } from './monitoring/app-monitoring.service';
import { map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { NB_WINDOW } from '@nebular/theme';

const socialLinks = [
  {
    url: 'https://www.facebook.com/************/',
    target: '_blank',
    icon: 'socicon-facebook',
  },
];

export class NbSimpleRoleProvider extends NbRoleProvider {
  getRole() {
    // here you could provide any role based on any auth flow
    return observableOf('guest');
  }
}

// Override 'token' to 'id_token' for Azure AD B2C
(NbOAuth2ResponseType as any)['TOKEN'] = 'id_token';

// Create new token for Azure auth so it returns id_token instead of access_token
export class NbAuthAzureToken extends NbAuthOAuth2Token {

  // let's rename it to exclude name clashes
  static NAME = 'nb:auth:azure:token';

  getValue(): string {
    return this.token.id_token;
  }
}

@Injectable()
export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  // we need this methos for strategy setup
  static setup(options: NbOAuth2AuthStrategyOptions): [NbAuthStrategyClass, NbOAuth2AuthStrategyOptions] {
    return [NbAzureADB2CAuthStrategy, options];
  }

  protected redirectResults: any = {
    [NbOAuth2ResponseType.CODE]: () => observableOf(null),

    [NbOAuth2ResponseType.TOKEN]: () => {

      // queryParams here as token returned not as hash
      return observableOf(this.activatedRoute.snapshot.queryParams).pipe(
        map((params: any) => {
          return !!(params && (params.id_token || params.error));
        }),
      );
    },
  };

  protected redirectResultHandlers = {
    [NbOAuth2ResponseType.CODE]: () => observableOf(null),
    [NbOAuth2ResponseType.TOKEN]: () => {

      // same here, token isn't in hash so have to redefine this to work with queryParams
      return observableOf(this.activatedRoute.snapshot.queryParams).pipe(
        map((params: any) => {
          if (!params.error) {
            return new NbAuthResult(
              true,
              params,
              this.getOption('redirect.success'),
              [],
              this.getOption('defaultMessages'),
              this.createToken(params));
          }

          return new NbAuthResult(
            false,
            params,
            this.getOption('redirect.failure'),
            this.getOption('defaultErrors'),
            [],
          );
        }),
      );
    },
  };

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              @Inject(NB_WINDOW) window: any) {
    super(http, activatedRoute, window);
  }
}

export const NB_CORE_PROVIDERS = [
  ...DataModule.forRoot().providers,
  ...NbAuthModule.forRoot({
    strategies: [
      NbAzureADB2CAuthStrategy.setup({
        name: 'AzureADB2C',
        clientId: '******************************',
        clientSecret: '',
        authorize: {
          // tslint:disable-next-line:max-line-length
          endpoint: 'https://login.microsoftonline.com/tfp/******.onmicrosoft.com/b2c_1_pxdrsignin/oauth2/v2.0/authorize',
          responseType: NbOAuth2ResponseType.TOKEN, // have overloaded this to return id_token instead of token for Azure auth
          scope: 'https://******.onmicrosoft.com/api/profile openid profile',
          redirectUri: 'https://localhost:5001/auth/callback',
        },
        token: {
          class: NbAuthAzureToken, // changed from NbAuthOAuth2Token
        },
        redirect: {
          success: '/dashboard/overview',
        },
      }),
    ],
    forms: {
      login: {
        socialLinks: socialLinks,
      },
      register: {
        socialLinks: socialLinks,
      },
    },
  }).providers,

  NbSecurityModule.forRoot({
    accessControl: {
      guest: {
        view: '*',
      },
      user: {
        parent: 'guest',
        create: '*',
        edit: '*',
        remove: '*',
      },
    },
  }).providers,
  {
    provide: NbRoleProvider, useClass: NbSimpleRoleProvider,
  },
  AppMonitoringService,
];

@NgModule({
  imports: [
    CommonModule,
  ],
  exports: [
    NbAuthModule,
  ],
  declarations: [],
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    throwIfAlreadyLoaded(parentModule, 'CoreModule');
  }

  static forRoot(): ModuleWithProviders {
    return <ModuleWithProviders>{
      ngModule: CoreModule,
      providers: [
        ...NB_CORE_PROVIDERS,
        NbAzureADB2CAuthStrategy,
      ],
    };
  }
}

May be something I've done wrong on either side of the section? This is the trace I'm now getting when the breakpoint in oauth2-strategy.js is hit (line 139):

1. Local
    1. result:false
    2. this:SwitchMapSubscriber
2. Closure (push../node_modules/@nebular/auth/strategies/oauth2/oauth2-strategy.js.NbOAuth2AuthStrategy.authenticate)
    1. _this:NbAzureADB2CAuthStrategy
        1. activatedRoute:ActivatedRoute {url: BehaviorSubject, params: BehaviorSubject, queryParams: BehaviorSubject, fragment: BehaviorSubject, data: BehaviorSubject, …}
        2. defaultOptions:NbOAuth2AuthStrategyOptions
            1. authorize:{endpoint: "authorize", responseType: "code"}
            2. baseEndpoint:""
            3. clientId:""
            4. clientSecret:""
            5. defaultErrors:["Something went wrong, please try again."]
            6. defaultMessages:["You have been successfully authenticated."]
            7. redirect:{success: "/", failure: null}
            8. refresh:{endpoint: "token", grantType: "refresh_token"}
            9. token:{endpoint: "token", grantType: "authorization_code", class: ƒ}
            10. __proto__:Object
        3. http:HttpClient {handler: HttpInterceptingHandler}
        4. options:
            1. authorize:{endpoint: "https://login.microsoftonline.com/tfp/******.onmicrosoft.com/b2c_1_pxdrsignin/oauth2/v2.0/authorize", responseType: "id_token", scope: "https://******.onmicrosoft.com/api/profile openid profile", redirectUri: "https://localhost:5001/auth/callback"}
            2. baseEndpoint:""
            3. clientId:"*********************************"
            4. clientSecret:""
            5. defaultErrors:["Something went wrong, please try again."]
            6. defaultMessages:["You have been successfully authenticated."]
            7. name:"AzureADB2C"
            8. redirect:{success: "/dashboard/overview", failure: null}
            9. refresh:{endpoint: "token", grantType: "refresh_token"}
            10. token:{endpoint: "token", grantType: "authorization_code", class: ƒ}
            11. __proto__:Object
        5. redirectResultHandlers:{code: ƒ, id_token: ƒ}
        6. redirectResults:{code: ƒ, id_token: ƒ}
        7. responseType:(...)
        8. route:ActivatedRoute {url: BehaviorSubject, params: BehaviorSubject, queryParams: BehaviorSubject, fragment: BehaviorSubject, data: BehaviorSubject, …}
        9. window:Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}
        10. __proto__:NbOAuth2AuthStrategy
3. Closure
    1. NbOAuth2AuthStrategy:ƒ NbOAuth2AuthStrategy(http, route, window)
    2. _super:ƒ NbAuthStrategy()
4. Closure (./node_modules/@nebular/auth/strategies/oauth2/oauth2-strategy.js)
5. Window
6. Global

Any ideas? Thanks again for your ongoing support with this

jjgriff93 commented 6 years ago

Hi @nnixaa any thoughts on what could be missing? Struggling to find anything that could still be causing the redirect

nnixaa commented 6 years ago

Hey @jjgriff93, I guess your router setting useHash is set to false, isn't it? I most likely missed that part modifying that example. This one works fine on my side (note I have to remove some of imported files as I don't have those):

import { ModuleWithProviders, NgModule, Optional, SkipSelf, Injectable, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// tslint:disable-next-line:max-line-length
import { NbOAuth2AuthStrategy, NbAuthModule, NbOAuth2ResponseType, NbAuthOAuth2Token, NbAuthResult, NbOAuth2AuthStrategyOptions, NbAuthStrategyClass } from '@nebular/auth';
import { NbSecurityModule, NbRoleProvider } from '@nebular/security';
import { of as observableOf } from 'rxjs';

import { throwIfAlreadyLoaded } from './module-import-guard';
import { DataModule } from './data/data.module';
import { map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
import { NB_WINDOW } from '@nebular/theme';
import { AnalyticsService } from './utils/analytics.service';
import { I18nService } from './data/i18n.service';

const socialLinks = [
  {
    url: 'https://www.facebook.com/************/',
    target: '_blank',
    icon: 'socicon-facebook',
  },
];

export class NbSimpleRoleProvider extends NbRoleProvider {
  getRole() {
    // here you could provide any role based on any auth flow
    return observableOf('guest');
  }
}

// Override 'token' to 'id_token' for Azure AD B2C
(NbOAuth2ResponseType as any)['TOKEN'] = 'id_token';

// Create new token for Azure auth so it returns id_token instead of access_token
export class NbAuthAzureToken extends NbAuthOAuth2Token {

  // let's rename it to exclude name clashes
  static NAME = 'nb:auth:azure:token';

  getValue(): string {
    return this.token.id_token;
  }
}

@Injectable()
export class NbAzureADB2CAuthStrategy extends NbOAuth2AuthStrategy {

  // we need this methos for strategy setup
  static setup(options: NbOAuth2AuthStrategyOptions): [NbAuthStrategyClass, NbOAuth2AuthStrategyOptions] {
    return [NbAzureADB2CAuthStrategy, options];
  }

  protected redirectResults: any = {
    [NbOAuth2ResponseType.CODE]: () => observableOf(null),

    [NbOAuth2ResponseType.TOKEN]: () => {
      return observableOf(this.activatedRoute.snapshot.fragment).pipe(
        map(fragment => this.parseHashAsQueryParams(fragment)),
        map((params: any) => !!(params && (params.id_token || params.error))),
      );
    },
  };

  constructor(http: HttpClient,
              private activatedRoute: ActivatedRoute,
              @Inject(NB_WINDOW) window: any) {
    super(http, activatedRoute, window);
  }
}

export const NB_CORE_PROVIDERS = [
  ...DataModule.forRoot().providers,
  ...NbAuthModule.forRoot({
    strategies: [
      NbAzureADB2CAuthStrategy.setup({
        name: 'AzureADB2C',
        clientId: '******************************',
        clientSecret: '',
        authorize: {
          // tslint:disable-next-line:max-line-length
          endpoint: 'https://login.microsoftonline.com/tfp/******.onmicrosoft.com/b2c_1_pxdrsignin/oauth2/v2.0/authorize',
          responseType: NbOAuth2ResponseType.TOKEN, // have overloaded this to return id_token instead of token for Azure auth
          scope: 'https://******.onmicrosoft.com/api/profile openid profile',
          redirectUri: 'https://localhost:5001/auth/callback',
        },
        token: {
          class: NbAuthAzureToken, // changed from NbAuthOAuth2Token
        },
        redirect: {
          success: '/dashboard/overview',
        },
      }),
    ],
    forms: {
      login: {
        socialLinks: socialLinks,
      },
      register: {
        socialLinks: socialLinks,
      },
    },
  }).providers,

  NbSecurityModule.forRoot({
    accessControl: {
      guest: {
        view: '*',
      },
      user: {
        parent: 'guest',
        create: '*',
        edit: '*',
        remove: '*',
      },
    },
  }).providers,
  {
    provide: NbRoleProvider, useClass: NbSimpleRoleProvider,
  },
  AnalyticsService,
  I18nService,
];

@NgModule({
  imports: [
    CommonModule,
  ],
  exports: [
    NbAuthModule,
  ],
  declarations: [],
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    throwIfAlreadyLoaded(parentModule, 'CoreModule');
  }

  static forRoot(): ModuleWithProviders {
    return <ModuleWithProviders>{
      ngModule: CoreModule,
      providers: [
        ...NB_CORE_PROVIDERS,
        NbAzureADB2CAuthStrategy,
      ],
    };
  }
}

To sum up, the main issue is "where to look for the token":

      return observableOf(this.activatedRoute.snapshot.fragment).pipe(

As some of the backend services return it as url hash (fragment), some as query params, so we need to be very careful with this and somehow take this into account inside of the strategy itself.

jjgriff93 commented 6 years ago

Hi @nnixaa - thank you, after comparing the above I noticed for some reason that I was missing map(fragment => this.parseHashAsQueryParams(fragment)), which looks like it was the key ingredient - now working :) I'll do some tests over the next few days and integrate identity properly to see if all of the values are parsed properly, but it's not continually redirecting anymore which is great news.

Can't thank you enough for your help! :)