Skip to content

feat: Multiple apps same domain [POC] #3167

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

Closed
wants to merge 8 commits into from
Closed
53 changes: 36 additions & 17 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ class AuthenticateContext {
}

public constructor(private clerkRequest: ClerkRequest, options: AuthenticateRequestOptions) {
// Even though the options are assigned to this later in this function
// we set the publishableKey here because it is being used in cookies/headers/handshake-values
// as part of getMultipleAppsCookie
this.publishableKey = options.publishableKey;

this.initHeaderValues();
this.initCookieValues();
this.initHandshakeValues();
Expand All @@ -48,35 +53,49 @@ class AuthenticateContext {

private initHandshakeValues() {
this.devBrowserToken =
this.clerkRequest.clerkUrl.searchParams.get(constants.Cookies.DevBrowser) ||
this.clerkRequest.cookies.get(constants.Cookies.DevBrowser);
this.getQueryParam(constants.Cookies.DevBrowser) || this.getMultipleAppsCookie(constants.Cookies.DevBrowser);
this.handshakeToken =
this.clerkRequest.clerkUrl.searchParams.get(constants.Cookies.Handshake) ||
this.clerkRequest.cookies.get(constants.Cookies.Handshake);
this.getQueryParam(constants.Cookies.Handshake) || this.getMultipleAppsCookie(constants.Cookies.Handshake);
}

private initHeaderValues() {
const get = (name: string) => this.clerkRequest.headers.get(name) || undefined;
this.sessionTokenInHeader = this.stripAuthorizationHeader(get(constants.Headers.Authorization));
this.origin = get(constants.Headers.Origin);
this.host = get(constants.Headers.Host);
this.forwardedHost = get(constants.Headers.ForwardedHost);
this.forwardedProto = get(constants.Headers.CloudFrontForwardedProto) || get(constants.Headers.ForwardedProto);
this.referrer = get(constants.Headers.Referrer);
this.userAgent = get(constants.Headers.UserAgent);
this.secFetchDest = get(constants.Headers.SecFetchDest);
this.accept = get(constants.Headers.Accept);
this.sessionTokenInHeader = this.stripAuthorizationHeader(this.getHeader(constants.Headers.Authorization));
this.origin = this.getHeader(constants.Headers.Origin);
this.host = this.getHeader(constants.Headers.Host);
this.forwardedHost = this.getHeader(constants.Headers.ForwardedHost);
this.forwardedProto =
this.getHeader(constants.Headers.CloudFrontForwardedProto) || this.getHeader(constants.Headers.ForwardedProto);
this.referrer = this.getHeader(constants.Headers.Referrer);
this.userAgent = this.getHeader(constants.Headers.UserAgent);
this.secFetchDest = this.getHeader(constants.Headers.SecFetchDest);
this.accept = this.getHeader(constants.Headers.Accept);
}

private initCookieValues() {
const get = (name: string) => this.clerkRequest.cookies.get(name) || undefined;
this.sessionTokenInCookie = get(constants.Cookies.Session);
this.clientUat = Number.parseInt(get(constants.Cookies.ClientUat) || '') || 0;
this.sessionTokenInCookie = this.getMultipleAppsCookie(constants.Cookies.Session);
this.clientUat = Number.parseInt(this.getMultipleAppsCookie(constants.Cookies.ClientUat) || '') || 0;
}

private stripAuthorizationHeader(authValue: string | undefined | null): string | undefined {
return authValue?.replace('Bearer ', '');
}

private getQueryParam(name: string) {
return this.clerkRequest.clerkUrl.searchParams.get(name);
}

private getHeader(name: string) {
return this.clerkRequest.headers.get(name) || undefined;
}

private getCookie(name: string) {
return this.clerkRequest.cookies.get(name) || undefined;
}

private getMultipleAppsCookie(cookieName: string) {
const suffix = this.publishableKey?.split('_').pop();
return this.getCookie(`${cookieName}_${suffix}`) || this.getCookie(cookieName) || undefined;
}
}

export type { AuthenticateContext };
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function authenticateRequest(

const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`);
url.searchParams.append('redirect_url', redirectUrl?.href || '');
url.searchParams.append('_multiple_apps_same_domain', 'true');

if (pk?.instanceType === 'development' && authenticateContext.devBrowserToken) {
url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken);
Expand All @@ -110,7 +111,7 @@ export async function authenticateRequest(
let sessionToken = '';
cookiesToSet.forEach((x: string) => {
headers.append('Set-Cookie', x);
if (x.startsWith(`${constants.Cookies.Session}=`)) {
if (x.startsWith(`${constants.Cookies.Session}`)) {
sessionToken = x.split(';')[0].substring(10);
}
});
Expand Down
5 changes: 3 additions & 2 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type { ActiveSessionResource, SignInJSON, SignUpJSON, TokenResource } fro
import { waitFor } from '@testing-library/dom';

import { mockNativeRuntime } from '../../testUtils';
import type { DevBrowser } from '../auth/devBrowser';
import { SessionCookieService } from '../auth/SessionCookieService';
import { Clerk } from '../clerk';
import type { DevBrowser } from '../devBrowser';
import { eventBus, events } from '../events';
import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, EmailLinkErrorCode, Environment, SignIn, SignUp } from '../resources/internal';
import { SessionCookieService } from '../services';
import { mockJwt } from '../test/fixtures';

const mockClientFetch = jest.fn();
Expand All @@ -19,6 +19,7 @@ jest.mock('../resources/Environment');
// Because Jest, don't ask me why...
jest.mock('../devBrowser', () => ({
createDevBrowser: (): DevBrowser => ({
migrate: jest.fn(),
clear: jest.fn(),
setup: jest.fn(),
getDevBrowserJWT: jest.fn(() => 'deadbeef'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createWorkerTimers } from '@clerk/shared';

import { SafeLock } from '../../../utils';
import { SafeLock } from './safeLock';

const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
const INTERVAL_IN_MS = 5 * 1000;
Expand Down
146 changes: 146 additions & 0 deletions packages/clerk-js/src/core/auth/SessionCookieService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser';
import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error';
import type { Clerk, EnvironmentResource } from '@clerk/types';

import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors';
import { eventBus, events } from '../events';
import type { FapiClient } from '../fapiClient';
import type { ClientUatCookieHandler } from './cookies/clientUat';
import { createClientUatCookie } from './cookies/clientUat';
import type { SessionCookieHandler } from './cookies/session';
import { createSessionCookie } from './cookies/session';
import type { DevBrowser } from './devBrowser';
import { createDevBrowser } from './devBrowser';
import { SessionCookiePoller } from './SessionCookiePoller';

// TODO: make SessionCookieService singleton since it handles updating cookies using a poller
// and we need to avoid updating them concurrently.
export class SessionCookieService {
private environment: EnvironmentResource | undefined;
private poller: SessionCookiePoller | null = null;
private clientUat: ClientUatCookieHandler;
private sessionCookie: SessionCookieHandler;
private devBrowser: DevBrowser;

constructor(private clerk: Clerk, fapiClient: FapiClient, multipleAppsSameDomainEnabled = false) {
// set cookie on token update
eventBus.on(events.TokenUpdate, ({ token }) => {
this.updateSessionCookie(token && token.getRawString());
this.setClientUatCookieForDevelopmentInstances();
});

this.refreshTokenOnVisibilityChange();
this.startPollingForToken();

this.clientUat = createClientUatCookie(clerk.publishableKey, multipleAppsSameDomainEnabled);
this.sessionCookie = createSessionCookie(clerk.publishableKey, multipleAppsSameDomainEnabled);
this.devBrowser = createDevBrowser(
{
publishableKey: clerk.publishableKey,
frontendApi: clerk.frontendApi,
fapiClient,
},
multipleAppsSameDomainEnabled,
);

this.migrateCookieValues();
}

public setEnvironment(environment: EnvironmentResource) {
this.environment = environment;
this.setClientUatCookieForDevelopmentInstances();
}

public isSignedOut() {
return this.clientUat.get() <= 0;
}

public async setupDevelopment() {
await this.devBrowser.setup();
}

public setupProduction() {
this.devBrowser.clear();
}

public async handleUnauthenticatedDevBrowser() {
this.devBrowser.clear();
await this.devBrowser.setup();
}

public urlWithAuth(url: URL): URL {
const devBrowserJwt = this.devBrowser.getDevBrowserJWT();
if (!devBrowserJwt) {
return clerkMissingDevBrowserJwt();
}

return setDevBrowserJWTInURL(url, devBrowserJwt);
}

private migrateCookieValues() {
this.clientUat.migrate();
this.sessionCookie.migrate();
this.devBrowser.migrate();
}

private startPollingForToken() {
if (!this.poller) {
this.poller = new SessionCookiePoller();
}
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
}

private refreshTokenOnVisibilityChange() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void this.refreshSessionToken();
}
});
}

private async refreshSessionToken(): Promise<void> {
if (!this.clerk.session) {
return;
}

try {
await this.clerk.session.getToken();
} catch (e) {
return this.handleGetTokenError(e);
}
}

private updateSessionCookie(token: string | null) {
return token ? this.sessionCookie.set(token) : this.sessionCookie.remove();
}

private setClientUatCookieForDevelopmentInstances() {
if (this.environment?.isDevelopmentOrStaging() && this.inCustomDevelopmentDomain()) {
this.clientUat.set(this.clerk.client);
}
}

private inCustomDevelopmentDomain() {
const domain = this.clerk.frontendApi.replace('clerk.', '');
return !window.location.host.endsWith(domain);
}

private handleGetTokenError(e: any) {
//throw if not a clerk error
if (!isClerkAPIResponseError(e)) {
clerkCoreErrorTokenRefreshFailed(e.message || e);
}

//sign user out if a 4XX error
if (is4xxError(e)) {
void this.clerk.handleUnauthenticated();
return;
}

if (isNetworkError(e)) {
return;
}

clerkCoreErrorTokenRefreshFailed(e.toString());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FapiClient } from '../../fapiClient';
import { createDevBrowser } from '../devBrowser';
import type { FapiClient } from '../fapiClient';

type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
Expand Down Expand Up @@ -46,6 +46,7 @@ describe('Thrown errors', () => {
const mockFapiClient = mockCreateFapiClient() as FapiClient;

const devBrowserHandler = createDevBrowser({
publishableKey: 'pk_test_d2hpdGUta29hbGEtNDIuY2xlcmsuYWNjb3VudHMuZGV2JA',
frontendApi: 'white-koala-42.clerk.accounts.dev',
fapiClient: mockFapiClient,
});
Expand Down
61 changes: 61 additions & 0 deletions packages/clerk-js/src/core/auth/cookies/clientUat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createCookieHandler } from '@clerk/shared/cookie';
import { addYears } from '@clerk/shared/date';
import type { ClientResource } from '@clerk/types';

import { inCrossOriginIframe } from '../../../utils';

const CLIENT_UAT_COOKIE_NAME = '__client_uat';

export type ClientUatCookieHandler = {
set: (client: ClientResource | undefined) => void;
get: () => number;
migrate: () => void;
};

export const createClientUatCookie = (publishableKey: string, withSuffix = false): ClientUatCookieHandler => {
const suffix = publishableKey.split('_').pop();
const clientUatCookieLegacy = createCookieHandler(CLIENT_UAT_COOKIE_NAME);
const clientUatCookieWithSuffix = createCookieHandler(`${CLIENT_UAT_COOKIE_NAME}_${suffix}`);

const clientUatCookie = withSuffix ? clientUatCookieWithSuffix : clientUatCookieLegacy;

const get = (): number => {
return parseInt(clientUatCookie.get() || '0', 10);
};

const set = (client: ClientResource | undefined) => {
const expires = addYears(Date.now(), 1);
const sameSite = inCrossOriginIframe() ? 'None' : 'Strict';
const secure = window.location.protocol === 'https:';

// '0' indicates the user is signed out
let val = '0';

if (client && client.updatedAt && client.activeSessions.length > 0) {
// truncate timestamp to seconds, since this is a unix timestamp
val = Math.floor(client.updatedAt.getTime() / 1000).toString();
}

return clientUatCookie.set(val, {
expires,
sameSite,
secure,
});
};

const migrate = () => {
if (!withSuffix || clientUatCookieWithSuffix.get()) return;

const legacyValue = clientUatCookieLegacy.get();
if (!legacyValue) return;

clientUatCookieWithSuffix.set(legacyValue);
clientUatCookieLegacy.remove();
};

return {
set,
get,
migrate,
};
};
Loading