Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions projects/auth0-angular/src/lib/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,13 +1039,19 @@ describe('AuthService', () => {
});

it('should call the underlying SDK', async () => {
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
const localService = createService();
await firstValueFrom(localService.handleRedirectCallback());
expect(auth0Client.handleRedirectCallback).toHaveBeenCalled();
});

it('should call the underlying SDK and pass options', async () => {
const url = 'http://localhost';
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
const localService = createService();
await firstValueFrom(localService.handleRedirectCallback(url));
expect(auth0Client.handleRedirectCallback).toHaveBeenCalledWith(url);
Expand Down Expand Up @@ -1074,8 +1080,70 @@ describe('AuthService', () => {
expect(auth0Client.isAuthenticated).toHaveBeenCalled();
});

it('should not navigate before isAuthenticated$ is true in Capacitor flow', async () => {
// Regression test for https://github.com/auth0/auth0-angular/issues/668
// In Capacitor, isLoading is already false when handleRedirectCallback runs.
// Bug: refresh() is async but navigateByUrl fired immediately, so AuthGuard
// read the stale cached false and triggered a second loginWithRedirect.
let authValueAtNavigation: boolean | undefined;

// Make the isAuthenticated call triggered by refresh() slow to resolve
let resolveRefreshAuth!: (val: boolean) => void;
const slowAuthPromise = new Promise<boolean>(
(resolve) => (resolveRefreshAuth = resolve)
);
let callCount = 0;
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockImplementation(() => {
callCount++;
return callCount === 1
? Promise.resolve(false) // initial check on load
: slowAuthPromise; // refresh call — deliberately slow
});

const localService = createService();

// Subscribe early to prime the shareReplay cache — mirrors AuthGuard being active
const authSub = localService.isAuthenticated$.subscribe();

// Capture the cached value of isAuthenticated$ at the moment navigateByUrl fires
(navigator.navigateByUrl as MockInstance).mockImplementation(() => {
let cached: boolean | undefined;
const sub = localService.isAuthenticated$.subscribe(
(v) => (cached = v)
);
sub.unsubscribe();
authValueAtNavigation = cached;
return Promise.resolve(true);
});

// Wait for initial load to complete (isLoading = false = Capacitor scenario)
await firstValueFrom(localService.isLoading$.pipe(filter((l) => !l)));

// Trigger handleRedirectCallback while isLoading is already false
const callbackPromise = firstValueFrom(
localService.handleRedirectCallback()
);

// Let the microtask queue drain so the callback pipeline runs, then resolve auth
await Promise.resolve();
resolveRefreshAuth(true);

await callbackPromise;
authSub.unsubscribe();

// navigateByUrl must only fire after isAuthenticated$ is true.
// On buggy code: authValueAtNavigation === false → this expectation FAILS
// With the fix: authValueAtNavigation === true → this expectation PASSES
expect(authValueAtNavigation).toBe(true);
});

it('should record the appState in the appState$ observable if it is present', async () => {
const appState = { myValue: 'State to Preserve' };
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
(
auth0Client.handleRedirectCallback as unknown as MockInstance
).mockResolvedValue({ appState });
Expand All @@ -1087,6 +1155,9 @@ describe('AuthService', () => {

it('should preserve appState as-is for regular login', async () => {
const appState = { myValue: 'State to Preserve' };
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
(
auth0Client.handleRedirectCallback as unknown as MockInstance
).mockResolvedValue({ appState, response_type: ResponseType.Code });
Expand All @@ -1105,6 +1176,9 @@ describe('AuthService', () => {
created_at: '2024-01-01T00:00:00.000Z',
expires_at: '2024-01-02T00:00:00.000Z',
};
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
(
auth0Client.handleRedirectCallback as unknown as MockInstance
).mockResolvedValue({
Expand All @@ -1130,6 +1204,9 @@ describe('AuthService', () => {
created_at: '2024-02-01T00:00:00.000Z',
expires_at: '2024-02-02T00:00:00.000Z',
};
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
(
auth0Client.handleRedirectCallback as unknown as MockInstance
).mockResolvedValue({
Expand Down
29 changes: 19 additions & 10 deletions projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
concatMap,
tap,
map,
filter,
take,
takeUntil,
catchError,
switchMap,
Expand Down Expand Up @@ -407,10 +409,7 @@ export class AuthService<TAppState extends AppState = AppState>
this.auth0Client.handleRedirectCallback<TAppState>(url)
).pipe(
withLatestFrom(this.authState.isLoading$),
tap(([result, isLoading]) => {
if (!isLoading) {
this.authState.refresh();
}
switchMap(([result, isLoading]) => {
const { appState, response_type, ...rest } = result;
const target = appState?.target ?? '/';

Expand All @@ -424,9 +423,22 @@ export class AuthService<TAppState extends AppState = AppState>
this.appStateSubject$.next(appState);
}

if (!isLoading) {
this.authState.refresh();
// Capacitor flow: app was already running so isLoading is false.
// refresh() is async — wait for isAuthenticated$ to become true
// before navigating so AuthGuard does not read the stale cached false.
return this.authState.isAuthenticated$.pipe(
filter((authenticated) => authenticated),
take(1),
tap(() => this.navigator.navigateByUrl(target)),
map(() => result)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

this.navigator.navigateByUrl(target);
}),
map(([result]) => result)
return of(result);
})
);
}

Expand Down Expand Up @@ -577,10 +589,7 @@ export class AuthService<TAppState extends AppState = AppState>
updateAuthenticationMethod: (
id: string,
data: UpdateAuthenticationMethodRequest
) =>
from(
this.auth0Client.myAccount.updateAuthenticationMethod(id, data)
),
) => from(this.auth0Client.myAccount.updateAuthenticationMethod(id, data)),
enrollmentChallenge: (options: EnrollmentChallengeOptions) =>
from(this.auth0Client.myAccount.enrollmentChallenge(options)),
enrollmentVerify: (options: EnrollmentVerifyOptions) =>
Expand Down
Loading