Skip to content
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

feat(backend,clerk-js,shared): Introduce suffixed / un-suffixed cookies #3274

Closed
wants to merge 9 commits into from
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];
};