Skip to content
Closed
8 changes: 8 additions & 0 deletions .changeset/calm-readers-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/backend': minor
'@clerk/shared': minor
---

Support reading / writing / removing suffixed/un-suffixed cookies from `@clerk/clerk-js` and `@clerk/backend`.
Everyone of `__session`, `__clerk_db_jwt` and `__client_uat` cookies will also be set with a suffix to support multiple apps on the same domain.
54 changes: 34 additions & 20 deletions integration/tests/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -185,7 +185,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand All @@ -207,7 +209,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand All @@ -230,7 +234,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -254,7 +258,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -278,7 +282,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -300,7 +304,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://example.com/clerk/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand All @@ -324,7 +330,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -346,7 +352,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand All @@ -367,7 +375,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}${devBrowserQuery}`,
)}&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -386,7 +394,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand Down Expand Up @@ -485,7 +495,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(app.serverUrl + '/')}`,
`https://clerk.example.com/v1/client/handshake?redirect_url=${encodeURIComponent(
app.serverUrl + '/',
)}&suffixed_cookies=true`,
);
});

Expand Down Expand Up @@ -520,7 +532,9 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(`${app.serverUrl}/`)}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&suffixed_cookies=true`,
);
});

Expand All @@ -543,7 +557,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}hello%3Ffoo%3Dbar${devBrowserQuery}`,
)}hello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -566,7 +580,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}hello%3Ffoo%3Dbar`,
)}hello%3Ffoo%3Dbar&suffixed_cookies=true`,
);
});

Expand All @@ -589,7 +603,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -612,7 +626,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`,
);
});

Expand All @@ -635,7 +649,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar${devBrowserQuery}`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true${devBrowserQuery}`,
);
});

Expand All @@ -658,7 +672,7 @@ test.describe('Client handshake @generic', () => {
});
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar`,
`https://${config.pkHost}/v1/client/handshake?redirect_url=https%3A%2F%2Fexample.com%3A3213%2Fhello%3Ffoo%3Dbar&suffixed_cookies=true`,
);
});

Expand Down Expand Up @@ -787,7 +801,7 @@ test.describe('Client handshake @generic', () => {
expect(res.headers.get('location')).toBe(
`https://${config.pkHost}/v1/client/handshake?redirect_url=${encodeURIComponent(
`${app.serverUrl}/`,
)}&__clerk_db_jwt=asdf`,
)}&suffixed_cookies=true&__clerk_db_jwt=asdf`,
);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { API_URL, API_VERSION, constants, USER_AGENT } from '../constants';
// DO NOT CHANGE: Runtime needs to be imported as a default export so that we can stub its dependencies with Sinon.js
// For more information refer to https://sinonjs.org/how-to/stub-dependency/
import runtime from '../runtime';
import { assertValidSecretKey } from '../util/assertValidSecretKey';
import { assertValidSecretKey } from '../util/optionsAssertions';
import { joinPaths } from '../util/path';
import { deserialize } from './resources/Deserializer';

Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const Cookies = {
ClientUat: '__client_uat',
Handshake: '__clerk_handshake',
DevBrowser: '__clerk_db_jwt',
SuffixedCookies: '__clerk_suffixed_cookies',
} as const;

const QueryParameters = {
Expand Down
94 changes: 74 additions & 20 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { parsePublishableKey } from '@clerk/shared/keys';

import { constants } from '../constants';
import { assertValidPublishableKey } from '../util/optionsAssertions';
import type { ClerkRequest } from './clerkRequest';
import type { AuthenticateRequestOptions } from './types';

Expand All @@ -16,13 +19,18 @@ interface AuthenticateContextInterface extends AuthenticateRequestOptions {
// cookie-based values
sessionTokenInCookie: string | undefined;
clientUat: number;
suffixedCookies: boolean;
// handshake-related values
devBrowserToken: string | undefined;
handshakeToken: string | undefined;
// url derived from headers
clerkUrl: URL;
// cookie or header session token
sessionToken: string | undefined;
// enforce existence of the following props
publishableKey: string;
instanceType: string;
frontendApi: string;
}

interface AuthenticateContext extends AuthenticateContextInterface {}
Expand All @@ -38,45 +46,91 @@ class AuthenticateContext {
return this.sessionTokenInCookie || this.sessionTokenInHeader;
}

private get cookieSuffix() {
return this.publishableKey?.split('_').pop();
}

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.initPublishableKeyValues(options);
this.initHeaderValues();
// initCookieValues should be used before initHandshakeValues because the it depends on suffixedCookies
this.initCookieValues();
this.initHandshakeValues();
Object.assign(this, options);
this.clerkUrl = this.clerkRequest.clerkUrl;
}

private initHandshakeValues() {
this.devBrowserToken =
this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) ||
this.clerkRequest.cookies.get(constants.Cookies.DevBrowser);
this.handshakeToken =
this.clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.Handshake) ||
this.clerkRequest.cookies.get(constants.Cookies.Handshake);
private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;

const pk = parsePublishableKey(this.publishableKey, {
fatal: true,
proxyUrl: options.proxyUrl,
domain: options.domain,
});
this.instanceType = pk.instanceType;
this.frontendApi = pk.frontendApi;
}

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;
// suffixedCookies needs to be set first because it's used in getMultipleAppsCookie
this.suffixedCookies = this.getSuffixedCookie(constants.Cookies.SuffixedCookies) === 'true';
this.sessionTokenInCookie = this.getSuffixedOrUnSuffixedCookie(constants.Cookies.Session);
this.clientUat = Number.parseInt(this.getSuffixedOrUnSuffixedCookie(constants.Cookies.ClientUat) || '') || 0;
}

private initHandshakeValues() {
this.devBrowserToken =
this.getQueryParam(constants.QueryParameters.DevBrowser) ||
this.getSuffixedOrUnSuffixedCookie(constants.Cookies.DevBrowser);
// Using getCookie since we don't suffix the handshake token cookie
this.handshakeToken =
this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake);
}

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 getSuffixedCookie(name: string) {
return this.getCookie(`${name}_${this.cookieSuffix}`) || undefined;
}

private getSuffixedOrUnSuffixedCookie(cookieName: string) {
if (this.suffixedCookies) {
return this.getSuffixedCookie(cookieName);
}
return this.getCookie(cookieName);
}
}

export type { AuthenticateContext };
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/tokens/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const getCookieName = (cookieDirective: string): string => {
return cookieDirective.split(';')[0]?.split('=')[0];
};

export const getCookieValue = (cookieDirective: string): string => {
return cookieDirective.split(';')[0]?.split('=')[1];
};
Loading