Skip to content

Add a few new event helpers to the Cordova redirect handler #4445

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 11, 2021
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { inMemoryPersistence } from './in_memory';

export const enum KeyName {
AUTH_USER = 'authUser',
AUTH_EVENT = 'authEvent',
REDIRECT_USER = 'redirectUser',
PERSISTENCE_USER = 'persistence'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,35 @@
* limitations under the License.
*/

import { FirebaseError } from '@firebase/util';
import { expect } from 'chai';
import * as sinonChai from 'sinon-chai';
import * as sinon from 'sinon';
import { FirebaseError, querystring } from '@firebase/util';
import { expect, use } from 'chai';
import { testAuth, TestAuth } from '../../../test/helpers/mock_auth';
import { AuthEventType } from '../../model/popup_redirect';
import { _generateNewEvent } from './events';
import { AuthEvent, AuthEventType } from '../../model/popup_redirect';
import {
_eventFromPartialAndUrl,
_generateNewEvent,
_getAndRemoveEvent,
_getDeepLinkFromCallback,
_savePartialEvent
} from './events';
import { _createError } from '../../core/util/assert';
import { AuthErrorCode } from '../../core/errors';

use(sinonChai);

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

beforeEach(async () => {
auth = await testAuth();
storageStub = sinon.stub(localStorage);
});

afterEach(() => {
sinon.restore();
});

describe('_generateNewEvent', () => {
Expand All @@ -51,4 +69,127 @@ describe('platform_cordova/popup_redirect/events', () => {
.with.property('code', 'auth/no-auth-event');
});
});

describe('_savePartialEvent', () => {
it('sets the event', async () => {
const event = _generateNewEvent(auth, AuthEventType.REAUTH_VIA_REDIRECT);
await _savePartialEvent(auth, event);
expect(storageStub.setItem).to.have.been.calledWith(
'firebase:authEvent:test-api-key:test-app',
JSON.stringify(event)
);
});
});

describe('_getAndRemoveEvent', () => {
it('returns null if no event is present', async () => {
storageStub.getItem.returns(null);
expect(await _getAndRemoveEvent(auth)).to.be.null;
});

it('returns the event and deletes the key if present', async () => {
const event = JSON.stringify(
_generateNewEvent(auth, AuthEventType.REAUTH_VIA_REDIRECT)
);
storageStub.getItem.returns(event);
expect(await _getAndRemoveEvent(auth)).to.eql(JSON.parse(event));
expect(storageStub.removeItem).to.have.been.calledWith(
'firebase:authEvent:test-api-key:test-app'
);
});
});

describe('_eventFromPartialAndUrl', () => {
let partialEvent: AuthEvent;
beforeEach(() => {
partialEvent = _generateNewEvent(
auth,
AuthEventType.REAUTH_VIA_REDIRECT,
'id'
);
});

function generateCallbackUrl(params: Record<string, string>): string {
const deepLink = `http://foo/__/auth/callback?${querystring(params)}`;
return `http://outer-app?link=${encodeURIComponent(deepLink)}`;
}

it('returns the proper event if everything is correct w/ no error', () => {
const url = generateCallbackUrl({});
expect(_eventFromPartialAndUrl(partialEvent, url)).to.eql({
type: AuthEventType.REAUTH_VIA_REDIRECT,
eventId: 'id',
tenantId: null,
sessionId: partialEvent.sessionId,
urlResponse: 'http://foo/__/auth/callback?',
postBody: null
});
});

it('returns null if the callback url has no link', () => {
expect(_eventFromPartialAndUrl(partialEvent, 'http://foo')).to.be.null;
});

it('generates an error if the callback has an error', () => {
const handlerError = _createError(AuthErrorCode.INTERNAL_ERROR);
const url = generateCallbackUrl({
'firebaseError': JSON.stringify(handlerError)
});
const { error, ...rest } = _eventFromPartialAndUrl(partialEvent, url)!;

expect(error)
.to.be.instanceOf(FirebaseError)
.with.property('code', 'auth/internal-error');
expect(rest).to.eql({
type: AuthEventType.REAUTH_VIA_REDIRECT,
eventId: 'id',
tenantId: null,
urlResponse: null,
sessionId: null,
postBody: null
});
});
});

describe('_getDeepLinkFromCallback', () => {
it('returns the iOS double deep link preferentially', () => {
expect(
_getDeepLinkFromCallback(
'https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep' +
'&deep_link_id=http%3A%2F%2Ffoo%3Flink%3DdoubleDeepIos'
)
).to.eq('doubleDeepIos');
});

it('returns the iOS deep link preferentially', () => {
expect(
_getDeepLinkFromCallback(
'https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep' +
'&deep_link_id=http%3A%2F%2FfooIOS'
)
).to.eq('http://fooIOS');
});

it('returns double deep link preferentially', () => {
expect(
_getDeepLinkFromCallback(
'https://foo?link=http%3A%2F%2Ffoo%3Flink%3DdoubleDeep'
)
).to.eq('doubleDeep');
});

it('returns the deep link preferentially', () => {
expect(
_getDeepLinkFromCallback(
'https://foo?link=http%3A%2F%2Ffoo%3Funrelated%3Dyeah'
)
).to.eq('http://foo?unrelated=yeah');
});

it('returns the passed-in url when all else fails', () => {
expect(_getDeepLinkFromCallback('https://foo?bar=baz')).to.eq(
'https://foo?bar=baz'
);
});
});
});
117 changes: 117 additions & 0 deletions packages-exp/auth-exp/src/platform_cordova/popup_redirect/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@
* limitations under the License.
*/

import { querystringDecode } from '@firebase/util';
import { AuthErrorCode } from '../../core/errors';
import { PersistedBlob, Persistence } from '../../core/persistence';
import {
KeyName,
_persistenceKeyName
} from '../../core/persistence/persistence_user_manager';
import { _createError } from '../../core/util/assert';
import { _getInstance } from '../../core/util/instantiator';
import { Auth } from '../../model/auth';
import { AuthEvent, AuthEventType } from '../../model/popup_redirect';
import { browserLocalPersistence } from '../../platform_browser/persistence/local_storage';

const SESSION_ID_LENGTH = 20;

Expand All @@ -41,6 +49,72 @@ export function _generateNewEvent(
};
}

export function _savePartialEvent(auth: Auth, event: AuthEvent): Promise<void> {
return storage()._set(
persistenceKey(auth),
(event as object) as PersistedBlob
);
}

export async function _getAndRemoveEvent(
auth: Auth
): Promise<AuthEvent | null> {
const event = (await storage()._get(
persistenceKey(auth)
)) as AuthEvent | null;
if (event) {
await storage()._remove(persistenceKey(auth));
}
return event;
}

export function _eventFromPartialAndUrl(
partialEvent: AuthEvent,
url: string
): AuthEvent | null {
// Parse the deep link within the dynamic link URL.
const callbackUrl = _getDeepLinkFromCallback(url);
// Confirm it is actually a callback URL.
// Currently the universal link will be of this format:
// https://<AUTH_DOMAIN>/__/auth/callback<OAUTH_RESPONSE>
// This is a fake URL but is not intended to take the user anywhere
// and just redirect to the app.
if (callbackUrl.includes('/__/auth/callback')) {
// Check if there is an error in the URL.
// This mechanism is also used to pass errors back to the app:
// https://<AUTH_DOMAIN>/__/auth/callback?firebaseError=<STRINGIFIED_ERROR>
const params = searchParamsOrEmpty(callbackUrl);
// Get the error object corresponding to the stringified error if found.
const errorObject = params['firebaseError']
? parseJsonOrNull(decodeURIComponent(params['firebaseError']))
: null;
const code = errorObject?.['code']?.split('auth/')?.[1];
const error = code ? _createError(code) : null;
if (error) {
return {
type: partialEvent.type,
eventId: partialEvent.eventId,
tenantId: partialEvent.tenantId,
error,
urlResponse: null,
sessionId: null,
postBody: null
};
} else {
return {
type: partialEvent.type,
eventId: partialEvent.eventId,
tenantId: partialEvent.tenantId,
sessionId: partialEvent.sessionId,
urlResponse: callbackUrl,
postBody: null
};
}
}

return null;
}

function generateSessionId(): string {
const chars = [];
const allowedChars =
Expand All @@ -51,3 +125,46 @@ function generateSessionId(): string {
}
return chars.join('');
}

function storage(): Persistence {
return _getInstance(browserLocalPersistence);
}

function persistenceKey(auth: Auth): string {
return _persistenceKeyName(KeyName.AUTH_EVENT, auth.config.apiKey, auth.name);
}

function parseJsonOrNull(json: string): ReturnType<typeof JSON.parse> | null {
try {
return JSON.parse(json);
} catch (e) {
return null;
}
}

// Exported for testing
export function _getDeepLinkFromCallback(url: string): string {
const params = searchParamsOrEmpty(url);
const link = params['link'] ? decodeURIComponent(params['link']) : undefined;
// Double link case (automatic redirect)
const doubleDeepLink = searchParamsOrEmpty(link)['link'];
// iOS custom scheme links.
const iOSDeepLink = params['deep_link_id']
? decodeURIComponent(params['deep_link_id'])
: undefined;
const iOSDoubleDeepLink = searchParamsOrEmpty(iOSDeepLink)['link'];
return iOSDoubleDeepLink || iOSDeepLink || doubleDeepLink || link || url;
}

/**
* Optimistically tries to get search params from a string, or else returns an
* empty search params object.
*/
function searchParamsOrEmpty(url: string | undefined): Record<string, string> {
if (!url?.includes('?')) {
return {};
}

const [_, ...rest] = url.split('?');
return querystringDecode(rest.join('?')) as Record<string, string>;
}