Skip to content

Commit

Permalink
App State Observable (#168)
Browse files Browse the repository at this point in the history
  • Loading branch information
fearnycompknowhow authored Jun 16, 2021
1 parent 179b97d commit e9b216b
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 6 deletions.
53 changes: 53 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
2. [User is not logged in after successful sign in with redirect](#2-user-is-not-logged-in-after-successful-sign-in-with-redirect)
3. [User is redirected to `/` after successful sign in with redirect](#3-user-is-redirected-to--after-successful-sign-in-with-redirect)
4. [Getting an infinite redirect loop between my application and Auth0](#4-getting-an-infinite-redirect-loop-between-my-application-and-auth0)
5. [Preserve application state through redirects](#5-preserve-application-state-through-redirects)

## 1. User is not logged in after page refresh

Expand Down Expand Up @@ -61,3 +62,55 @@ this.authService.loginWithRedirect({
In situations where the `redirectUri` points to a _protected_ route, your application will end up in an infinite redirect loop between your application and Auth0.

The `redirectUri` should always be a **public** route in your application (even if the entire application is secure, our SDK needs a public route to be redirected back to). This is because, when redirecting back to the application, there is no user information available yet. The SDK first needs to process the URL (`code` and `state` query parameters) and call Auth0's endpoints to exchange the code for a token. Once that is successful, the user is considered authenticated.

## 5. Preserve application state through redirects

To preserve application state through the redirect to Auth0 and the subsequent redirect back to your application (if the user authenticates successfully), you can pass in the state that you want preserved to the `loginWithRedirect` method:

```ts
import { Component } from '@angular/core';
import { AuthService } from '@auth0/auth0-angular';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(public auth: AuthService) {}

loginWithRedirect(): void {
this.auth.loginWithRedirect({
appState: {
myValue: 'My State to Preserve',
},
});
}
}
```

After Auth0 redirects the user back to your application, you can access the stored state using the `appState$` observable on the `AuthService`:

```ts
import { Component, OnInit } from '@angular/core';
import { AuthService } from '@auth0/auth0-angular';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(public auth: AuthService) {}

ngOnInit() {
this.auth.appState$.subscribe((appState) => {
console.log(appState.myValue);
});
}
}
```

> By default, this method of saving application state will store it in Session Storage; however, if `useCookiesForTransactions` is set, a Cookie will be used instead.
> This information will be removed from storage once the user is redirected back to your application after a successful login attempt (although it will continue to be accessible on the `appState$` observable).
35 changes: 35 additions & 0 deletions projects/auth0-angular/src/lib/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,23 @@ describe('AuthService', () => {
});
});

it('should record the appState in the appState$ observable if it is present', (done) => {
const appState = {
myValue: 'State to Preserve',
};

(auth0Client.handleRedirectCallback as jasmine.Spy).and.resolveTo({
appState,
});

const localService = createService();

localService.appState$.subscribe((recievedState) => {
expect(recievedState).toEqual(appState);
done();
});
});

it('should record errors in the error$ observable', (done) => {
const errorObj = new Error('An error has occured');

Expand Down Expand Up @@ -695,6 +712,24 @@ describe('AuthService', () => {
)
.subscribe();
});

it('should record the appState in the appState$ observable if it is present', (done) => {
const appState = {
myValue: 'State to Preserve',
};

(auth0Client.handleRedirectCallback as jasmine.Spy).and.resolveTo({
appState,
});

const localService = createService();
localService.handleRedirectCallback().subscribe(() => {
localService.appState$.subscribe((recievedState) => {
expect(recievedState).toEqual(appState);
done();
});
});
});
});

describe('buildAuthorizeUrl', () => {
Expand Down
15 changes: 14 additions & 1 deletion projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class AuthService implements OnDestroy {
private errorSubject$ = new ReplaySubject<Error>(1);
private refreshState$ = new Subject<void>();
private accessToken$ = new ReplaySubject<string>(1);
private appStateSubject$ = new ReplaySubject<any>(1);

// https://stackoverflow.com/a/41177163
private ngUnsubscribe$ = new Subject<void>();
Expand Down Expand Up @@ -137,6 +138,12 @@ export class AuthService implements OnDestroy {
*/
readonly error$ = this.errorSubject$.asObservable();

/**
* Emits the value (if any) that was passed to the `loginWithRedirect` method call
* but only **after** `handleRedirectCallback` is first called
*/
readonly appState$ = this.appStateSubject$.asObservable();

constructor(
@Inject(Auth0ClientService) private auth0Client: Auth0Client,
private configFactory: AuthClientConfig,
Expand Down Expand Up @@ -333,7 +340,13 @@ export class AuthService implements OnDestroy {
if (!isLoading) {
this.refreshState$.next();
}
const target = result?.appState?.target ?? '/';
const appState = result?.appState;
const target = appState?.target ?? '/';

if (appState) {
this.appStateSubject$.next(appState);
}

this.navigator.navigateByUrl(target);
}),
map(([result]) => result)
Expand Down
7 changes: 6 additions & 1 deletion projects/playground/e2e/integration/playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ describe('Smoke tests', () => {
cy.get('#logout').should('be.visible').click();
});

it('do redirect login and show user and access token', () => {
it('do redirect login and show user, access token and appState', () => {
const appState = 'Any Random String';

cy.visit('/');
cy.get('[data-cy=app-state-input]').type(appState);
cy.get('#login').should('be.visible').click();
cy.url().should('include', 'https://brucke.auth0.com/login');
loginToAuth0();
Expand All @@ -66,6 +69,8 @@ describe('Smoke tests', () => {
cy.get('[data-cy=accessToken]').should('have.text', token);
});

cy.get('[data-cy=app-state-result]').should('have.value', appState);

cy.get('#logout').should('be.visible').click();
cy.get('#login').should('be.visible');
});
Expand Down
27 changes: 27 additions & 0 deletions projects/playground/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ <h2>Authentication</h2>
data-cy="login-popup"
/>
</label>

<br />
<br />

<label>
App State:
<input
type="text"
formControlName="appStateInput"
data-cy="app-state-input"
/>
</label>

<br />
<br />

<button id="login" (click)="launchLogin()">Log in</button>
<button id="loginWithInvitation" (click)="loginHandleInvitationUrl()">
Log in with Invitation
Expand Down Expand Up @@ -132,6 +148,17 @@ <h2>Artifacts</h2>
<textarea data-cy="accessToken" cols="50" rows="2" disabled="true">{{
accessToken
}}</textarea>

<br />
<br />

<textarea
data-cy="app-state-result"
cols="50"
rows="2"
disabled="true"
>{{ appStateResult }}</textarea
>
</li>
</ul>
</div>
Expand Down
12 changes: 10 additions & 2 deletions projects/playground/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TestBed, ComponentFixture } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { AuthService } from 'projects/auth0-angular/src/lib/auth.service';
import { BehaviorSubject, of } from 'rxjs';
import { BehaviorSubject, of, ReplaySubject } from 'rxjs';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

Expand All @@ -26,6 +26,7 @@ describe('AppComponent', () => {
user$: new BehaviorSubject(null),
isLoading$: new BehaviorSubject(true),
isAuthenticated$: new BehaviorSubject(false),
appState$: new ReplaySubject(),
}
) as any;

Expand Down Expand Up @@ -291,15 +292,22 @@ describe('AppComponent', () => {
});

it('should login with redirect', () => {
const appStateValue = 'Value to Preserve';

const wrapLogin = ne.querySelector('.login-wrapper');
const form = component.loginOptionsForm.controls;
form.usePopup.setValue(false);
form.appStateInput.setValue(appStateValue);

const btnRefresh = wrapLogin?.querySelector('button');
btnRefresh?.click();
fixture.detectChanges();

expect(authMock.loginWithRedirect).toHaveBeenCalledWith({});
expect(authMock.loginWithRedirect).toHaveBeenCalledWith({
appState: {
myValue: appStateValue,
},
});
});

it('should login with popup', () => {
Expand Down
15 changes: 13 additions & 2 deletions projects/playground/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, Inject } from '@angular/core';
import { Component, Inject, OnInit } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { AuthService } from 'projects/auth0-angular/src/lib/auth.service';
import { iif } from 'rxjs';
Expand All @@ -12,17 +12,19 @@ import { HttpClient } from '@angular/common/http';
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
export class AppComponent implements OnInit {
isAuthenticated$ = this.auth.isAuthenticated$;
isLoading$ = this.auth.isLoading$;
user$ = this.auth.user$;
claims$ = this.auth.idTokenClaims$;
accessToken = '';
appStateResult = '';
error$ = this.auth.error$;

organization = '';

loginOptionsForm = new FormGroup({
appStateInput: new FormControl(''),
usePopup: new FormControl(false),
});

Expand All @@ -36,6 +38,12 @@ export class AppComponent {
ignoreCache: new FormControl(false),
});

ngOnInit(): void {
this.auth.appState$.subscribe((appState) => {
this.appStateResult = appState.myValue;
});
}

constructor(
public auth: AuthService,
@Inject(DOCUMENT) private doc: Document,
Expand All @@ -51,6 +59,9 @@ export class AppComponent {
} else {
this.auth.loginWithRedirect({
...(this.organization ? { organization: this.organization } : null),
appState: {
myValue: this.loginOptionsForm.value.appStateInput,
},
});
}
}
Expand Down

0 comments on commit e9b216b

Please sign in to comment.