Skip to content

Commit c08b194

Browse files
committed
Add callback listeners to the cordova redirect handler
1 parent 7afacba commit c08b194

File tree

6 files changed

+280
-11
lines changed

6 files changed

+280
-11
lines changed

packages-exp/auth-exp/src/core/auth/auth_event_manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ const EVENT_DUPLICATION_CACHE_DURATION_MS = 10 * 60 * 1000;
3232
export class AuthEventManager implements EventManager {
3333
private readonly cachedEventUids: Set<string> = new Set();
3434
private readonly consumers: Set<AuthEventConsumer> = new Set();
35-
private queuedRedirectEvent: AuthEvent | null = null;
36-
private hasHandledPotentialRedirect = false;
35+
protected queuedRedirectEvent: AuthEvent | null = null;
36+
protected hasHandledPotentialRedirect = false;
3737
private lastProcessedEventTime = Date.now();
3838

3939
constructor(private readonly auth: Auth) {}

packages-exp/auth-exp/src/core/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ type GenericAuthErrorParams = {
402402
| AuthErrorCode.DEPENDENT_SDK_INIT_BEFORE_AUTH
403403
| AuthErrorCode.INTERNAL_ERROR
404404
| AuthErrorCode.MFA_REQUIRED
405+
| AuthErrorCode.NO_AUTH_EVENT
405406
>]: {
406407
appName: AppName;
407408
email?: string;
@@ -413,6 +414,7 @@ export interface AuthErrorParams extends GenericAuthErrorParams {
413414
[AuthErrorCode.ARGUMENT_ERROR]: { appName?: AppName };
414415
[AuthErrorCode.DEPENDENT_SDK_INIT_BEFORE_AUTH]: { appName?: AppName };
415416
[AuthErrorCode.INTERNAL_ERROR]: { appName?: AppName };
417+
[AuthErrorCode.NO_AUTH_EVENT]: { appName?: AppName };
416418
[AuthErrorCode.MFA_REQUIRED]: {
417419
appName: AppName;
418420
serverResponse: IdTokenMfaResponse;

packages-exp/auth-exp/src/platform_cordova/plugins.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@ declare namespace cordova.plugins.browsertab {
2525
}
2626

2727
declare namespace cordova.InAppBrowser {
28-
function open(url: string, target: string, options: string): unknown;
28+
function open(url: string, target: string, options: string): InAppBrowserRef;
2929
}
3030

3131
declare namespace universalLinks {
32-
function subscribe(n: null, cb: (event: unknown) => void): void;
32+
function subscribe(n: null, cb: (event: Record<string, string>|null) => void): void;
3333
}
3434

3535
declare namespace BuildInfo {
3636
const packageName: string;
3737
const displayName: string;
3838
}
39+
40+
declare function handleOpenUrl(url: string): void;
41+
42+
declare interface InAppBrowserRef {
43+
close?: () => void;
44+
}

packages-exp/auth-exp/src/platform_cordova/popup_redirect/popup_redirect.test.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import { SingletonInstantiator } from '../../core/util/instantiator';
2525
import {
2626
AuthEvent,
2727
AuthEventType,
28+
EventManager,
2829
PopupRedirectResolver
2930
} from '../../model/popup_redirect';
30-
import { cordovaPopupRedirectResolver } from './popup_redirect';
31+
import { CordovaAuthEventManager, cordovaPopupRedirectResolver } from './popup_redirect';
3132
import { GoogleAuthProvider } from '../../core/providers/google';
3233
import * as utils from './utils';
3334
import * as events from './events';
3435
import { FirebaseError } from '@firebase/util';
36+
import { stubSingleTimeout, TimerTripFn } from '../../../test/helpers/timeout_stub';
3537

3638
use(chaiAsPromised);
3739
use(sinonChai);
@@ -85,6 +87,164 @@ describe('platform_cordova/popup_redirect/popup_redirect', () => {
8587
});
8688
});
8789

90+
describe('_initialize', () => {
91+
const NO_EVENT_TIMER_ID = 10001;
92+
const PACKAGE_NAME = 'my.package';
93+
const NOT_PACKAGE_NAME = 'not.my.package';
94+
let universalLinksCb: ((eventData: Record<string, string>|null) => unknown)|null;
95+
let tripNoEventTimer: TimerTripFn;
96+
97+
beforeEach(() => {
98+
tripNoEventTimer = stubSingleTimeout(NO_EVENT_TIMER_ID);
99+
window.universalLinks = {
100+
subscribe(_unused, cb) {
101+
universalLinksCb = cb;
102+
},
103+
};
104+
window.BuildInfo = {
105+
packageName: PACKAGE_NAME,
106+
displayName: '',
107+
};
108+
sinon.stub(window, 'clearTimeout');
109+
});
110+
111+
afterEach(() => {
112+
universalLinksCb = null;
113+
});
114+
115+
function event(manager: EventManager): Promise<AuthEvent> {
116+
return new Promise(resolve => {
117+
(manager as CordovaAuthEventManager).addPassiveListener(resolve);
118+
});
119+
}
120+
121+
context('when no event is present', () => {
122+
it('clears local storage and dispatches no-event event', async () => {
123+
const promise = event(await resolver._initialize(auth));
124+
tripNoEventTimer();
125+
const {error, ...rest} = await promise;
126+
127+
expect(error).to.be.instanceOf(FirebaseError).with.property('code', 'auth/no-auth-event');
128+
expect(rest).to.eql({
129+
type: AuthEventType.UNKNOWN,
130+
eventId: null,
131+
sessionId: null,
132+
urlResponse: null,
133+
postBody: null,
134+
tenantId: null,
135+
});
136+
expect(events._getAndRemoveEvent).to.have.been.called;
137+
});
138+
});
139+
140+
context('when an event is present', () => {
141+
it('clears the no event timeout',async () => {
142+
await resolver._initialize(auth);
143+
await universalLinksCb!({});
144+
expect(window.clearTimeout).to.have.been.calledWith(NO_EVENT_TIMER_ID);
145+
});
146+
147+
it('signals no event if no url in event data', async () => {
148+
const promise = event(await resolver._initialize(auth));
149+
await universalLinksCb!({});
150+
const {error, ...rest} = await promise;
151+
152+
expect(error).to.be.instanceOf(FirebaseError).with.property('code', 'auth/no-auth-event');
153+
expect(rest).to.eql({
154+
type: AuthEventType.UNKNOWN,
155+
eventId: null,
156+
sessionId: null,
157+
urlResponse: null,
158+
postBody: null,
159+
tenantId: null,
160+
});
161+
});
162+
163+
it('signals no event if partial parse turns up null', async () => {
164+
const promise = event(await resolver._initialize(auth));
165+
eventsStubs._eventFromPartialAndUrl.returns(null);
166+
eventsStubs._getAndRemoveEvent.returns(Promise.resolve({
167+
type: AuthEventType.REAUTH_VIA_REDIRECT,
168+
} as AuthEvent));
169+
await universalLinksCb!({url: 'foo-bar'});
170+
const {error, ...rest} = await promise;
171+
172+
expect(error).to.be.instanceOf(FirebaseError).with.property('code', 'auth/no-auth-event');
173+
expect(rest).to.eql({
174+
type: AuthEventType.UNKNOWN,
175+
eventId: null,
176+
sessionId: null,
177+
urlResponse: null,
178+
postBody: null,
179+
tenantId: null,
180+
});
181+
});
182+
183+
it('signals the final event if partial expansion success', async () => {
184+
const finalEvent = {
185+
type: AuthEventType.REAUTH_VIA_REDIRECT,
186+
postBody: 'foo',
187+
};
188+
eventsStubs._getAndRemoveEvent.returns(Promise.resolve({
189+
type: AuthEventType.REAUTH_VIA_REDIRECT,
190+
} as AuthEvent));
191+
192+
const promise = event(await resolver._initialize(auth));
193+
eventsStubs._eventFromPartialAndUrl.returns(finalEvent as AuthEvent);
194+
await universalLinksCb!({url: 'foo-bar'});
195+
expect(await promise).to.eq(finalEvent);
196+
expect(events._eventFromPartialAndUrl).to.have.been.calledWith(
197+
{type: AuthEventType.REAUTH_VIA_REDIRECT},
198+
'foo-bar'
199+
);
200+
});
201+
});
202+
203+
context('when using global handleOpenUrl callback', () => {
204+
it('ignores inbound callbacks that are not for this app', async () => {
205+
await resolver._initialize(auth);
206+
handleOpenUrl(`${NOT_PACKAGE_NAME}://foo`);
207+
208+
// Clear timeout is called in the handler so we can check that
209+
expect(window.clearTimeout).not.to.have.been.called;
210+
});
211+
212+
it('passes through callback if package name matches', async () => {
213+
await resolver._initialize(auth);
214+
handleOpenUrl(`${PACKAGE_NAME}://foo`);
215+
expect(window.clearTimeout).to.have.been.calledWith(NO_EVENT_TIMER_ID);
216+
});
217+
218+
it('signals the final event if partial expansion success', async () => {
219+
const finalEvent = {
220+
type: AuthEventType.REAUTH_VIA_REDIRECT,
221+
postBody: 'foo',
222+
};
223+
eventsStubs._getAndRemoveEvent.returns(Promise.resolve({
224+
type: AuthEventType.REAUTH_VIA_REDIRECT,
225+
} as AuthEvent));
226+
227+
const promise = event(await resolver._initialize(auth));
228+
eventsStubs._eventFromPartialAndUrl.returns(finalEvent as AuthEvent);
229+
handleOpenUrl(`${PACKAGE_NAME}://foo`);
230+
expect(await promise).to.eq(finalEvent);
231+
expect(events._eventFromPartialAndUrl).to.have.been.calledWith(
232+
{type: AuthEventType.REAUTH_VIA_REDIRECT},
233+
`${PACKAGE_NAME}://foo`
234+
);
235+
});
236+
237+
it('calls the dev existing handleOpenUrl function', async () => {
238+
const oldHandleOpenUrl = sinon.stub();
239+
window.handleOpenUrl = oldHandleOpenUrl;
240+
241+
await resolver._initialize(auth);
242+
handleOpenUrl(`${PACKAGE_NAME}://foo`);
243+
expect(oldHandleOpenUrl).to.have.been.calledWith(`${PACKAGE_NAME}://foo`);
244+
});
245+
});
246+
});
247+
88248
describe('_openPopup', () => {
89249
it('throws an error', () => {
90250
expect(() =>

packages-exp/auth-exp/src/platform_cordova/popup_redirect/popup_redirect.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,67 @@ import * as externs from '@firebase/auth-types-exp';
2020
import { browserSessionPersistence } from '../../platform_browser/persistence/session_storage';
2121
import { Auth } from '../../model/auth';
2222
import {
23+
AuthEvent,
2324
AuthEventType,
24-
EventManager,
2525
PopupRedirectResolver
2626
} from '../../model/popup_redirect';
2727
import { AuthPopup } from '../../platform_browser/util/popup';
28-
import { _fail } from '../../core/util/assert';
28+
import { _createError, _fail } from '../../core/util/assert';
2929
import { AuthErrorCode } from '../../core/errors';
3030
import {
3131
_checkCordovaConfiguration,
3232
_generateHandlerUrl,
3333
_performRedirect
3434
} from './utils';
35-
import { _generateNewEvent } from './events';
35+
import { _eventFromPartialAndUrl, _generateNewEvent, _getAndRemoveEvent } from './events';
36+
import { AuthEventManager } from '../../core/auth/auth_event_manager';
37+
38+
/**
39+
* How long to wait for the initial auth event before concluding no
40+
* redirect pending
41+
*/
42+
const INITIAL_EVENT_TIMEOUT_MS = 500;
43+
44+
/** Custom AuthEventManager that adds passive listeners to events */
45+
export class CordovaAuthEventManager extends AuthEventManager {
46+
private readonly passiveListeners: Set<(e: AuthEvent) => void> = new Set();
47+
48+
addPassiveListener(cb: (e: AuthEvent) => void): void {
49+
this.passiveListeners.add(cb);
50+
}
51+
52+
removePassiveListener(cb: (e: AuthEvent) => void): void {
53+
this.passiveListeners.delete(cb);
54+
}
55+
56+
// In a Cordova environment, this manager can live through multiple redirect
57+
// operations
58+
resetRedirect(): void {
59+
this.queuedRedirectEvent = null;
60+
this.hasHandledPotentialRedirect = false;
61+
}
62+
63+
/** Override the onEvent method */
64+
onEvent(event: AuthEvent): boolean {
65+
this.passiveListeners.forEach(cb => cb(event));
66+
return super.onEvent(event);
67+
}
68+
}
3669

3770
class CordovaPopupRedirectResolver implements PopupRedirectResolver {
3871
readonly _redirectPersistence = browserSessionPersistence;
72+
private readonly eventManagers: Record<string, CordovaAuthEventManager> = {};
73+
3974
_completeRedirectFn: () => Promise<null> = async () => null;
4075

41-
_initialize(_auth: Auth): Promise<EventManager> {
42-
throw new Error('Method not implemented.');
76+
async _initialize(auth: Auth): Promise<CordovaAuthEventManager> {
77+
const key = auth._key();
78+
if (!this.eventManagers[key]) {
79+
const manager = new CordovaAuthEventManager(auth);
80+
this.eventManagers[key] = manager;
81+
this.attachCallbackListeners(auth, manager);
82+
}
83+
return this.eventManagers[key];
4384
}
4485

4586
_openPopup(auth: Auth): Promise<AuthPopup> {
@@ -64,6 +105,66 @@ class CordovaPopupRedirectResolver implements PopupRedirectResolver {
64105
): void {
65106
throw new Error('Method not implemented.');
66107
}
108+
109+
private attachCallbackListeners(auth: Auth, manager: AuthEventManager): void {
110+
const noEvent: AuthEvent = {
111+
type: AuthEventType.UNKNOWN,
112+
eventId: null,
113+
sessionId: null,
114+
urlResponse: null,
115+
postBody: null,
116+
tenantId: null,
117+
error: _createError(AuthErrorCode.NO_AUTH_EVENT),
118+
};
119+
120+
const noEventTimeout = setTimeout(async () => {
121+
// We didn't see that initial event. Clear any pending object and
122+
// dispatch no event
123+
await _getAndRemoveEvent(auth);
124+
manager.onEvent(noEvent);
125+
}, INITIAL_EVENT_TIMEOUT_MS);
126+
127+
const universalLinksCb = async (eventData: Record<string, string>|null): Promise<void> => {
128+
// We have an event so we can clear the no event timeout
129+
clearTimeout(noEventTimeout);
130+
131+
const partialEvent = await _getAndRemoveEvent(auth);
132+
let finalEvent: AuthEvent|null = noEvent;
133+
// Start with the noEvent
134+
if (partialEvent && eventData?.['url']) {
135+
finalEvent = _eventFromPartialAndUrl(partialEvent, eventData['url']);
136+
}
137+
manager.onEvent(finalEvent || noEvent);
138+
};
139+
140+
// iOS 7 or 8 custom URL schemes.
141+
// This is also the current default behavior for iOS 9+.
142+
// For this to work, cordova-plugin-customurlscheme needs to be installed.
143+
// https://github.com/EddyVerbruggen/Custom-URL-scheme
144+
// Do not overwrite the existing developer's URL handler.
145+
const existingHandleOpenUrl = window.handleOpenUrl;
146+
window.handleOpenUrl = async url => {
147+
if (url.toLowerCase().startsWith(`${BuildInfo.packageName.toLowerCase()}://`)) {
148+
// We want this intentionally to float
149+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
150+
universalLinksCb({url});
151+
}
152+
// Call the developer's handler if it is present.
153+
if (typeof existingHandleOpenUrl === 'function') {
154+
try {
155+
existingHandleOpenUrl(url);
156+
} catch (e) {
157+
// This is a developer error. Don't stop the flow of the SDK.
158+
console.error(e);
159+
}
160+
}
161+
};
162+
163+
// Universal links subscriber doesn't exist for iOS, so we need to check
164+
if (typeof universalLinks.subscribe === 'function') {
165+
universalLinks.subscribe(null, universalLinksCb);
166+
}
167+
}
67168
}
68169

69170
/**

packages-exp/auth-exp/test/helpers/timeout_stub.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import * as sinon from 'sinon';
1919

20-
interface TimerTripFn {
20+
export interface TimerTripFn {
2121
(): void;
2222
}
2323

0 commit comments

Comments
 (0)