Skip to content

Commit ac05b13

Browse files
authored
Add a few new event helpers to the Cordova redirect handler (#4445)
* Add more cordova event handling * Formatting * PR feedback * Formatting
1 parent 384b64d commit ac05b13

File tree

3 files changed

+263
-4
lines changed

3 files changed

+263
-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: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,35 @@
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 {
25+
_eventFromPartialAndUrl,
26+
_generateNewEvent,
27+
_getAndRemoveEvent,
28+
_getDeepLinkFromCallback,
29+
_savePartialEvent
30+
} from './events';
31+
import { _createError } from '../../core/util/assert';
32+
import { AuthErrorCode } from '../../core/errors';
33+
34+
use(sinonChai);
2335

2436
describe('platform_cordova/popup_redirect/events', () => {
2537
let auth: TestAuth;
38+
let storageStub: sinon.SinonStubbedInstance<typeof localStorage>;
2639

2740
beforeEach(async () => {
2841
auth = await testAuth();
42+
storageStub = sinon.stub(localStorage);
43+
});
44+
45+
afterEach(() => {
46+
sinon.restore();
2947
});
3048

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

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,18 @@
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 {
22+
KeyName,
23+
_persistenceKeyName
24+
} from '../../core/persistence/persistence_user_manager';
1925
import { _createError } from '../../core/util/assert';
26+
import { _getInstance } from '../../core/util/instantiator';
2027
import { Auth } from '../../model/auth';
2128
import { AuthEvent, AuthEventType } from '../../model/popup_redirect';
29+
import { browserLocalPersistence } from '../../platform_browser/persistence/local_storage';
2230

2331
const SESSION_ID_LENGTH = 20;
2432

@@ -41,6 +49,72 @@ export function _generateNewEvent(
4149
};
4250
}
4351

52+
export function _savePartialEvent(auth: Auth, event: AuthEvent): Promise<void> {
53+
return storage()._set(
54+
persistenceKey(auth),
55+
(event as object) as PersistedBlob
56+
);
57+
}
58+
59+
export async function _getAndRemoveEvent(
60+
auth: Auth
61+
): Promise<AuthEvent | null> {
62+
const event = (await storage()._get(
63+
persistenceKey(auth)
64+
)) as AuthEvent | null;
65+
if (event) {
66+
await storage()._remove(persistenceKey(auth));
67+
}
68+
return event;
69+
}
70+
71+
export function _eventFromPartialAndUrl(
72+
partialEvent: AuthEvent,
73+
url: string
74+
): AuthEvent | null {
75+
// Parse the deep link within the dynamic link URL.
76+
const callbackUrl = _getDeepLinkFromCallback(url);
77+
// Confirm it is actually a callback URL.
78+
// Currently the universal link will be of this format:
79+
// https://<AUTH_DOMAIN>/__/auth/callback<OAUTH_RESPONSE>
80+
// This is a fake URL but is not intended to take the user anywhere
81+
// and just redirect to the app.
82+
if (callbackUrl.includes('/__/auth/callback')) {
83+
// Check if there is an error in the URL.
84+
// This mechanism is also used to pass errors back to the app:
85+
// https://<AUTH_DOMAIN>/__/auth/callback?firebaseError=<STRINGIFIED_ERROR>
86+
const params = searchParamsOrEmpty(callbackUrl);
87+
// Get the error object corresponding to the stringified error if found.
88+
const errorObject = params['firebaseError']
89+
? parseJsonOrNull(decodeURIComponent(params['firebaseError']))
90+
: null;
91+
const code = errorObject?.['code']?.split('auth/')?.[1];
92+
const error = code ? _createError(code) : null;
93+
if (error) {
94+
return {
95+
type: partialEvent.type,
96+
eventId: partialEvent.eventId,
97+
tenantId: partialEvent.tenantId,
98+
error,
99+
urlResponse: null,
100+
sessionId: null,
101+
postBody: null
102+
};
103+
} else {
104+
return {
105+
type: partialEvent.type,
106+
eventId: partialEvent.eventId,
107+
tenantId: partialEvent.tenantId,
108+
sessionId: partialEvent.sessionId,
109+
urlResponse: callbackUrl,
110+
postBody: null
111+
};
112+
}
113+
}
114+
115+
return null;
116+
}
117+
44118
function generateSessionId(): string {
45119
const chars = [];
46120
const allowedChars =
@@ -51,3 +125,46 @@ function generateSessionId(): string {
51125
}
52126
return chars.join('');
53127
}
128+
129+
function storage(): Persistence {
130+
return _getInstance(browserLocalPersistence);
131+
}
132+
133+
function persistenceKey(auth: Auth): string {
134+
return _persistenceKeyName(KeyName.AUTH_EVENT, auth.config.apiKey, auth.name);
135+
}
136+
137+
function parseJsonOrNull(json: string): ReturnType<typeof JSON.parse> | null {
138+
try {
139+
return JSON.parse(json);
140+
} catch (e) {
141+
return null;
142+
}
143+
}
144+
145+
// Exported for testing
146+
export function _getDeepLinkFromCallback(url: string): string {
147+
const params = searchParamsOrEmpty(url);
148+
const link = params['link'] ? decodeURIComponent(params['link']) : undefined;
149+
// Double link case (automatic redirect)
150+
const doubleDeepLink = searchParamsOrEmpty(link)['link'];
151+
// iOS custom scheme links.
152+
const iOSDeepLink = params['deep_link_id']
153+
? decodeURIComponent(params['deep_link_id'])
154+
: undefined;
155+
const iOSDoubleDeepLink = searchParamsOrEmpty(iOSDeepLink)['link'];
156+
return iOSDoubleDeepLink || iOSDeepLink || doubleDeepLink || link || url;
157+
}
158+
159+
/**
160+
* Optimistically tries to get search params from a string, or else returns an
161+
* empty search params object.
162+
*/
163+
function searchParamsOrEmpty(url: string | undefined): Record<string, string> {
164+
if (!url?.includes('?')) {
165+
return {};
166+
}
167+
168+
const [_, ...rest] = url.split('?');
169+
return querystringDecode(rest.join('?')) as Record<string, string>;
170+
}

0 commit comments

Comments
 (0)