Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Loading