Skip to content

Commit c7b2524

Browse files
committed
Add more cordova event handling
1 parent 7e711e5 commit c7b2524

File tree

3 files changed

+205
-4
lines changed

3 files changed

+205
-4
lines changed

packages-exp/auth-exp/src/core/persistence/persistence_user_manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { inMemoryPersistence } from './in_memory';
2424

2525
export const enum KeyName {
2626
AUTH_USER = 'authUser',
27+
AUTH_EVENT = 'authEvent',
2728
REDIRECT_USER = 'redirectUser',
2829
PERSISTENCE_USER = 'persistence'
2930
}

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

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,29 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { FirebaseError } from '@firebase/util';
19-
import { expect } from 'chai';
18+
import * as sinonChai from 'sinon-chai';
19+
import * as sinon from 'sinon';
20+
import { FirebaseError, querystring } from '@firebase/util';
21+
import { expect, use } from 'chai';
2022
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
21-
import { AuthEventType } from '../../model/popup_redirect';
22-
import { _generateNewEvent } from './events';
23+
import { AuthEvent, AuthEventType } from '../../model/popup_redirect';
24+
import { _eventFromPartialAndUrl, _generateNewEvent, _getAndRemoveEvent, _getDeepLinkFromCallback, _savePartialEvent } from './events';
25+
import { _createError } from '../../core/util/assert';
26+
import { AuthErrorCode } from '../../core/errors';
27+
28+
use(sinonChai);
2329

2430
describe('platform_cordova/popup_redirect/events', () => {
2531
let auth: TestAuth;
32+
let storageStub: sinon.SinonStubbedInstance<typeof localStorage>;
2633

2734
beforeEach(async () => {
2835
auth = await testAuth();
36+
storageStub = sinon.stub(localStorage);
37+
});
38+
39+
afterEach(() => {
40+
sinon.restore();
2941
});
3042

3143
describe('_generateNewEvent', () => {
@@ -51,4 +63,98 @@ describe('platform_cordova/popup_redirect/events', () => {
5163
.with.property('code', 'auth/no-auth-event');
5264
});
5365
});
66+
67+
describe('_savePartialEvent', () => {
68+
it('sets the event',async () => {
69+
const event = _generateNewEvent(auth, AuthEventType.REAUTH_VIA_REDIRECT);
70+
await _savePartialEvent(auth, event);
71+
expect(storageStub.setItem).to.have.been.calledWith('firebase:authEvent:test-api-key:test-app', JSON.stringify(event));
72+
});
73+
});
74+
75+
describe('_getAndRemoveEvent', () => {
76+
it('returns null if no event is present', async () => {
77+
storageStub.getItem.returns(null);
78+
expect(await _getAndRemoveEvent(auth)).to.be.null;
79+
});
80+
81+
it('returns the event and deletes the key if present', async () => {
82+
const event = JSON.stringify(_generateNewEvent(auth, AuthEventType.REAUTH_VIA_REDIRECT));
83+
storageStub.getItem.returns(event);
84+
expect(await _getAndRemoveEvent(auth)).to.eql(JSON.parse(event));
85+
expect(storageStub.removeItem).to.have.been.calledWith('firebase:authEvent:test-api-key:test-app');
86+
});
87+
});
88+
89+
describe('_eventFromPartialAndUrl', () => {
90+
let partialEvent: AuthEvent;
91+
beforeEach(() => {
92+
partialEvent = _generateNewEvent(auth, AuthEventType.REAUTH_VIA_REDIRECT, 'id');
93+
});
94+
95+
function generateCallbackUrl(params: Record<string, string>): string {
96+
const deepLink = `http://foo/__/auth/callback?${querystring(params)}`;
97+
return `http://outer-app?link=${encodeURIComponent(deepLink)}`;
98+
}
99+
100+
it('returns the proper event if everything is correct w/ no error', () => {
101+
const url = generateCallbackUrl({});
102+
expect(_eventFromPartialAndUrl(partialEvent, url)).to.eql({
103+
type: AuthEventType.REAUTH_VIA_REDIRECT,
104+
eventId: 'id',
105+
tenantId: null,
106+
sessionId: partialEvent.sessionId,
107+
urlResponse: 'http://foo/__/auth/callback?',
108+
postBody: null,
109+
});
110+
});
111+
112+
it('returns null if the callback url has no link', () => {
113+
expect(_eventFromPartialAndUrl(partialEvent, 'http://foo')).to.be.null;
114+
});
115+
116+
it('generates an error if the callback has an error', () => {
117+
const handlerError = _createError(AuthErrorCode.INTERNAL_ERROR);
118+
const url = generateCallbackUrl({
119+
'firebaseError': JSON.stringify(handlerError),
120+
});
121+
const {error, ...rest} = _eventFromPartialAndUrl(partialEvent, url)!;
122+
123+
expect(error).to.be.instanceOf(FirebaseError).with.property('code', 'auth/internal-error');
124+
expect(rest).to.eql({
125+
type: AuthEventType.REAUTH_VIA_REDIRECT,
126+
eventId: 'id',
127+
tenantId: null,
128+
urlResponse: null,
129+
sessionId: null,
130+
postBody: null,
131+
});
132+
});
133+
});
134+
135+
describe('_getDeepLinkFromCallback', () => {
136+
it('returns the iOS double deep link preferentially', () => {
137+
expect(_getDeepLinkFromCallback('https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep' +
138+
'&deep_link_id=http%3A%2F%2Ffoo%3Flink%3DdoubleDeepIos')).to.eq('doubleDeepIos');
139+
});
140+
141+
it('returns the iOS deep link preferentially', () => {
142+
expect(_getDeepLinkFromCallback('https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep' +
143+
'&deep_link_id=http%3A%2F%2FfooIOS')).to.eq('http://fooIOS');
144+
});
145+
146+
it('returns double deep link preferentially', () => {
147+
expect(_getDeepLinkFromCallback('https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep')).to.eq('doubleDeep');
148+
});
149+
150+
it('returns the deep link preferentially', () => {
151+
expect(_getDeepLinkFromCallback('https://foo?link=http%3A%2F%2Ffoo%3Funrelated%3Dyeah')).to.eq(
152+
'http://foo?unrelated=yeah'
153+
);
154+
});
155+
156+
it('returns the passed-in url when all else fails', () => {
157+
expect(_getDeepLinkFromCallback('https://foo?bar=baz')).to.eq('https://foo?bar=baz');
158+
});
159+
});
54160
});

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
* limitations under the License.
1616
*/
1717

18+
import { querystringDecode } from '@firebase/util';
1819
import { AuthErrorCode } from '../../core/errors';
20+
import { PersistedBlob, Persistence } from '../../core/persistence';
21+
import { KeyName, _persistenceKeyName } from '../../core/persistence/persistence_user_manager';
1922
import { _createError } from '../../core/util/assert';
23+
import { _getInstance } from '../../core/util/instantiator';
2024
import { Auth } from '../../model/auth';
2125
import { AuthEvent, AuthEventType } from '../../model/popup_redirect';
26+
import { browserLocalPersistence } from '../../platform_browser/persistence/local_storage';
2227

2328
const SESSION_ID_LENGTH = 20;
2429

@@ -41,6 +46,61 @@ export function _generateNewEvent(
4146
};
4247
}
4348

49+
export function _savePartialEvent(auth: Auth, event: AuthEvent): Promise<void> {
50+
return storage()._set(key(auth), event as object as PersistedBlob);
51+
}
52+
53+
export async function _getAndRemoveEvent(auth: Auth): Promise<AuthEvent|null> {
54+
const event = await storage()._get(key(auth)) as AuthEvent | null;
55+
if (event) {
56+
await storage()._remove(key(auth));
57+
}
58+
return event;
59+
}
60+
61+
export function _eventFromPartialAndUrl(partialEvent: AuthEvent, url: string): AuthEvent|null {
62+
// Parse the deep link within the dynamic link URL.
63+
const callbackUrl = _getDeepLinkFromCallback(url);
64+
// Confirm it is actually a callback URL.
65+
// Currently the universal link will be of this format:
66+
// https://<AUTH_DOMAIN>/__/auth/callback<OAUTH_RESPONSE>
67+
// This is a fake URL but is not intended to take the user anywhere
68+
// and just redirect to the app.
69+
if (callbackUrl.includes('/__/auth/callback')) {
70+
// Check if there is an error in the URL.
71+
// This mechanism is also used to pass errors back to the app:
72+
// https://<AUTH_DOMAIN>/__/auth/callback?firebaseError=<STRINGIFIED_ERROR>
73+
const params = searchParamsOrEmpty(callbackUrl);
74+
// Get the error object corresponding to the stringified error if found.
75+
const errorObject = params['firebaseError'] ?
76+
JSON.parse(decodeURIComponent(params['firebaseError'])) : null;
77+
const code = errorObject?.['code']?.split('auth/')?.[1];
78+
const error = code ? _createError(code) : null;
79+
if (error) {
80+
return {
81+
type: partialEvent.type,
82+
eventId: partialEvent.eventId,
83+
tenantId: partialEvent.tenantId,
84+
error,
85+
urlResponse: null,
86+
sessionId: null,
87+
postBody: null,
88+
};
89+
} else {
90+
return {
91+
type: partialEvent.type,
92+
eventId: partialEvent.eventId,
93+
tenantId: partialEvent.tenantId,
94+
sessionId: partialEvent.sessionId,
95+
urlResponse: callbackUrl,
96+
postBody: null,
97+
};
98+
}
99+
}
100+
101+
return null;
102+
}
103+
44104
function generateSessionId(): string {
45105
const chars = [];
46106
const allowedChars =
@@ -51,3 +111,37 @@ function generateSessionId(): string {
51111
}
52112
return chars.join('');
53113
}
114+
115+
function storage(): Persistence {
116+
return _getInstance(browserLocalPersistence);
117+
}
118+
119+
function key(auth: Auth): string {
120+
return _persistenceKeyName(KeyName.AUTH_EVENT, auth.config.apiKey, auth.name);
121+
}
122+
123+
// Exported for testing
124+
export function _getDeepLinkFromCallback(url: string): string {
125+
const params = searchParamsOrEmpty(url);
126+
const link = params['link'] ? decodeURIComponent(params['link']) : undefined;
127+
// Double link case (automatic redirect)
128+
const doubleDeepLink = searchParamsOrEmpty(link)['link'];
129+
// iOS custom scheme links.
130+
const iOSDeepLink = params['deep_link_id'] ? decodeURIComponent(params['deep_link_id']) : undefined;
131+
const iOSDoubleDeepLink = searchParamsOrEmpty(iOSDeepLink)['link'];
132+
return iOSDoubleDeepLink || iOSDeepLink || doubleDeepLink || link || url;
133+
}
134+
135+
/**
136+
* Optimistically tries to get search params from a string, or else returns an
137+
* empty search params object.
138+
*/
139+
function searchParamsOrEmpty(url: string | undefined): Record<string, string> {
140+
if (!url?.includes('?')) {
141+
return {};
142+
}
143+
144+
const [_, ...rest] = url.split('?');
145+
return querystringDecode(rest.join('?')) as Record<string, string>;
146+
}
147+

0 commit comments

Comments
 (0)