Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
6 changes: 6 additions & 0 deletions .changeset/eight-heads-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/backend': minor
---

Add handling of new Handshake nonce flow when authenticating requests
98 changes: 98 additions & 0 deletions packages/backend/src/api/__tests__/ClientApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('ClientAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

describe('getHandshakePayload', () => {
it('successfully fetches the handshake payload with a valid nonce', async () => {
const mockHandshakePayload = {
directives: ['directive1', 'directive2'],
};

server.use(
http.get(
'https://api.clerk.test/v1/clients/handshake_payload',
validateHeaders(({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('nonce')).toBe('test-nonce-123');
return HttpResponse.json(mockHandshakePayload);
}),
),
);

const response = await apiClient.clients.getHandshakePayload({
nonce: 'test-nonce-123',
});

expect(response.directives).toEqual(['directive1', 'directive2']);
expect(response.directives.length).toBe(2);
});

it('handles error responses correctly', async () => {
const mockErrorPayload = {
code: 'invalid_nonce',
message: 'Invalid nonce provided',
long_message: 'The nonce provided is invalid or has expired',
meta: { param_name: 'nonce' },
};
const traceId = 'trace_id_handshake';

server.use(
http.get(
'https://api.clerk.test/v1/clients/handshake_payload',
validateHeaders(() => {
return HttpResponse.json(
{ errors: [mockErrorPayload], clerk_trace_id: traceId },
{ status: 400, headers: { 'cf-ray': traceId } },
);
}),
),
);

const errResponse = await apiClient.clients.getHandshakePayload({ nonce: 'invalid-nonce' }).catch(err => err);

expect(errResponse.clerkTraceId).toBe(traceId);
expect(errResponse.status).toBe(400);
expect(errResponse.errors[0].code).toBe('invalid_nonce');
expect(errResponse.errors[0].message).toBe('Invalid nonce provided');
expect(errResponse.errors[0].longMessage).toBe('The nonce provided is invalid or has expired');
expect(errResponse.errors[0].meta.paramName).toBe('nonce');
});

it('requires a nonce parameter', async () => {
server.use(
http.get(
'https://api.clerk.test/v1/clients/handshake_payload',
validateHeaders(() => {
return HttpResponse.json(
{
errors: [
{
code: 'missing_parameter',
message: 'Missing required parameter',
long_message: 'The nonce parameter is required',
meta: { param_name: 'nonce' },
},
],
},
{ status: 400 },
);
}),
),
);

// @ts-expect-error Testing invalid input
const errResponse = await apiClient.clients.getHandshakePayload({}).catch(err => err);

expect(errResponse.status).toBe(400);
expect(errResponse.errors[0].code).toBe('missing_parameter');
});
});
});
13 changes: 13 additions & 0 deletions packages/backend/src/api/endpoints/ClientApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import type { ClerkPaginationRequest } from '@clerk/types';
import { joinPaths } from '../../util/path';
import type { Client } from '../resources/Client';
import type { PaginatedResourceResponse } from '../resources/Deserializer';
import type { HandshakePayload } from '../resources/HandshakePayload';
import { AbstractAPI } from './AbstractApi';

const basePath = '/clients';

type GetHandshakePayloadParams = {
nonce: string;
};

export class ClientAPI extends AbstractAPI {
public async getClientList(params: ClerkPaginationRequest = {}) {
return this.request<PaginatedResourceResponse<Client[]>>({
Expand All @@ -31,4 +36,12 @@ export class ClientAPI extends AbstractAPI {
bodyParams: { token },
});
}

public async getHandshakePayload(queryParams: GetHandshakePayloadParams) {
return this.request<HandshakePayload>({
method: 'GET',
path: joinPaths(basePath, 'handshake_payload'),
queryParams,
});
}
}
18 changes: 0 additions & 18 deletions packages/backend/src/api/endpoints/HandshakePayloadApi.ts

This file was deleted.

10 changes: 3 additions & 7 deletions packages/backend/src/api/resources/HandshakePayload.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
export type HandshakePayloadJSON = {
nonce: string;
payload: string;
directives: string[];
};

export class HandshakePayload {
constructor(
readonly nonce: string,
readonly payload: string,
) {}
constructor(readonly directives: string[]) {}

static fromJSON(data: HandshakePayloadJSON): HandshakePayload {
return new HandshakePayload(data.nonce, data.payload);
return new HandshakePayload(data.directives);
}
}
15 changes: 11 additions & 4 deletions packages/backend/src/tokens/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,17 @@ export class HandshakeService {
const cookiesToSet: string[] = [];

if (this.authenticateContext.handshakeNonce) {
// TODO: implement handshake nonce handling, fetch handshake payload with nonce
console.warn('Clerk: Handshake nonce is not implemented yet.');
}
if (this.authenticateContext.handshakeToken) {
try {
const handshakePayload = await this.authenticateContext.apiClient?.clients.getHandshakePayload({
nonce: this.authenticateContext.handshakeNonce,
});
if (handshakePayload) {
cookiesToSet.push(...handshakePayload.directives);
}
} catch (error) {
console.error('Clerk: HandshakeService: error getting handshake payload:', error);
}
} else if (this.authenticateContext.handshakeToken) {
const handshakePayload = await verifyHandshakeToken(
this.authenticateContext.handshakeToken,
this.authenticateContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export async function authenticateRequest(
/**
* If we have a handshakeToken, resolve the handshake and attempt to return a definitive signed in or signed out state.
*/
if (authenticateContext.handshakeToken) {
if (authenticateContext.handshakeNonce || authenticateContext.handshakeToken) {
try {
return await handshakeService.resolveHandshake();
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2637,6 +2637,7 @@ export class Clerk implements ClerkInterface {
// in the meantime, we're removing it here to keep the URL clean
removeClerkQueryParam(CLERK_SUFFIXED_COOKIES);
removeClerkQueryParam('__clerk_handshake');
removeClerkQueryParam('__clerk_handshake_nonce');
removeClerkQueryParam('__clerk_help');
} catch {
// ignore
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/getClerkQueryParam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const _ClerkQueryParams = [
'__clerk_ticket',
'__clerk_modal_state',
'__clerk_handshake',
'__clerk_handshake_nonce',
'__clerk_help',
CLERK_NETLIFY_CACHE_BUST_PARAM,
CLERK_SYNCED,
Expand Down