Skip to content

Commit a9fe242

Browse files
authored
chore(*): Improve @clerk/backend DX [Part 6 - token and jwt utils return values] (#2377)
* chore(backend,types): Introduce ReturnWithError and use it as `verifyToken` return value * chore(backend,types): Make /jwt subpath utilies to return `ReturnWithError` type * chore(backend,types): Rename and move `ReturnWithError` move types to backend `JwtReturnType`
1 parent 5f58a22 commit a9fe242

17 files changed

+305
-165
lines changed

.changeset/dull-ants-argue.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@clerk/backend': major
3+
---
4+
5+
Change return value of `verifyToken()` from `@clerk/backend` to `{ data, error}`.
6+
To replicate the current behaviour use this:
7+
```typescript
8+
import { verifyToken } from '@clerk/backend'
9+
10+
const { data, error } = await verifyToken(...);
11+
if(error){
12+
throw error;
13+
}
14+
```

.changeset/mighty-rice-marry.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/types': minor
3+
---
4+
5+
Introduce new `ResultWithError` type in `@clerk/types`

.changeset/proud-trees-yell.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@clerk/backend': major
3+
'@clerk/nextjs': major
4+
'@clerk/types': major
5+
---
6+
7+
Change return values of `signJwt`, `hasValidSignature`, `decodeJwt`, `verifyJwt`
8+
to return `{ data, error }`. Example of keeping the same behavior using those utilities:
9+
```typescript
10+
import { signJwt, hasValidSignature, decodeJwt, verifyJwt } from '@clerk/backend/jwt';
11+
12+
const { data, error } = await signJwt(...)
13+
if (error) throw error;
14+
15+
const { data, error } = await hasValidSignature(...)
16+
if (error) throw error;
17+
18+
const { data, error } = decodeJwt(...)
19+
if (error) throw error;
20+
21+
const { data, error } = await verifyJwt(...)
22+
if (error) throw error;
23+
```

packages/backend/README.md

+12-13
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates).
103103
```js
104104
import { verifyToken } from '@clerk/backend';
105105

106-
verifyToken(token, {
106+
const { result, error } = await verifyToken(token, {
107107
issuer: '...',
108108
authorizedParties: '...',
109109
});
@@ -114,11 +114,10 @@ verifyToken(token, {
114114
Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates). The key needs to be provided in the options.
115115

116116
```js
117-
import { verifyJwt } from '@clerk/backend';
117+
import { verifyJwt } from '@clerk/backend/jwt';
118118

119-
verifyJwt(token, {
119+
const { result, error } = verifyJwt(token, {
120120
key: JsonWebKey | string,
121-
issuer: '...',
122121
authorizedParties: '...',
123122
});
124123
```
@@ -128,27 +127,27 @@ verifyJwt(token, {
128127
Decodes a JWT.
129128

130129
```js
131-
import { decodeJwt } from '@clerk/backend';
130+
import { decodeJwt } from '@clerk/backend/jwt';
132131

133-
decodeJwt(token);
132+
const { result, error } = decodeJwt(token);
134133
```
135134

136135
#### hasValidSignature(jwt: Jwt, key: JsonWebKey | string)
137136

138137
Verifies that the JWT has a valid signature. The key needs to be provided.
139138

140139
```js
141-
import { hasValidSignature } from '@clerk/backend';
140+
import { hasValidSignature } from '@clerk/backend/jwt';
142141

143-
hasValidSignature(token, jwk);
142+
const { result, error } = await hasValidSignature(token, jwk);
144143
```
145144

146145
#### debugRequestState(requestState)
147146

148147
Generates a debug payload for the request state
149148

150149
```js
151-
import { debugRequestState } from '@clerk/backend';
150+
import { debugRequestState } from '@clerk/backend/internal';
152151

153152
debugRequestState(requestState);
154153
```
@@ -158,7 +157,7 @@ debugRequestState(requestState);
158157
Builds the AuthObject when the user is signed in.
159158

160159
```js
161-
import { signedInAuthObject } from '@clerk/backend';
160+
import { signedInAuthObject } from '@clerk/backend/internal';
162161

163162
signedInAuthObject(jwtPayload, options);
164163
```
@@ -168,7 +167,7 @@ signedInAuthObject(jwtPayload, options);
168167
Builds the empty AuthObject when the user is signed out.
169168

170169
```js
171-
import { signedOutAuthObject } from '@clerk/backend';
170+
import { signedOutAuthObject } from '@clerk/backend/internal';
172171

173172
signedOutAuthObject();
174173
```
@@ -178,7 +177,7 @@ signedOutAuthObject();
178177
Removes sensitive private metadata from user and organization resources in the AuthObject
179178

180179
```js
181-
import { sanitizeAuthObject } from '@clerk/backend';
180+
import { sanitizeAuthObject } from '@clerk/backend/internal';
182181

183182
sanitizeAuthObject(authObject);
184183
```
@@ -188,7 +187,7 @@ sanitizeAuthObject(authObject);
188187
Removes any `private_metadata` and `privateMetadata` attributes from the object to avoid leaking sensitive information to the browser during SSR.
189188

190189
```js
191-
import { prunePrivateMetadata } from '@clerk/backend';
190+
import { prunePrivateMetadata } from '@clerk/backend/internal';
192191

193192
prunePrivateMetadata(obj);
194193
```

packages/backend/src/__tests__/exports.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default (QUnit: QUnit) => {
1818
module('subpath /errors exports', () => {
1919
test('should not include a breaking change', assert => {
2020
const exportedApiKeys = [
21+
'SignJWTError',
2122
'TokenVerificationError',
2223
'TokenVerificationErrorAction',
2324
'TokenVerificationErrorCode',

packages/backend/src/errors.ts

+2
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,5 @@ export class TokenVerificationError extends Error {
6060
})`;
6161
}
6262
}
63+
64+
export class SignJWTError extends Error {}

packages/backend/src/jwt/__tests__/signJwt.test.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
publicJwks,
1010
signingJwks,
1111
} from '../../fixtures';
12+
import { assertOk } from '../../util/testUtils';
1213
import { signJwt } from '../signJwt';
1314
import { verifyJwt } from '../verifyJwt';
1415

@@ -26,22 +27,24 @@ export default (QUnit: QUnit) => {
2627
});
2728

2829
test('signs a JWT with a JWK formatted secret', async assert => {
29-
const jwt = await signJwt(payload, signingJwks, {
30+
const { data } = await signJwt(payload, signingJwks, {
3031
algorithm: mockJwtHeader.alg,
3132
header: mockJwtHeader,
3233
});
34+
assertOk(assert, data);
3335

34-
const verifiedPayload = await verifyJwt(jwt, { key: publicJwks });
36+
const { data: verifiedPayload } = await verifyJwt(data, { key: publicJwks });
3537
assert.deepEqual(verifiedPayload, payload);
3638
});
3739

3840
test('signs a JWT with a pkcs8 formatted secret', async assert => {
39-
const jwt = await signJwt(payload, pemEncodedSignKey, {
41+
const { data } = await signJwt(payload, pemEncodedSignKey, {
4042
algorithm: mockJwtHeader.alg,
4143
header: mockJwtHeader,
4244
});
45+
assertOk(assert, data);
4346

44-
const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey });
47+
const { data: verifiedPayload } = await verifyJwt(data, { key: pemEncodedPublicKey });
4548
assert.deepEqual(verifiedPayload, payload);
4649
});
4750
});
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Jwt } from '@clerk/types';
12
import type QUnit from 'qunit';
23
import sinon from 'sinon';
34

@@ -11,66 +12,72 @@ import {
1112
signedJwt,
1213
someOtherPublicKey,
1314
} from '../../fixtures';
15+
import { assertOk } from '../../util/testUtils';
1416
import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt';
1517

1618
export default (QUnit: QUnit) => {
1719
const { module, test } = QUnit;
20+
const invalidTokenError = {
21+
reason: 'token-invalid',
22+
message: 'Invalid JWT form. A JWT consists of three parts separated by dots.',
23+
};
1824

1925
module('hasValidSignature(jwt, key)', () => {
2026
test('verifies the signature with a JWK formatted key', async assert => {
21-
assert.true(await hasValidSignature(decodeJwt(signedJwt), publicJwks));
27+
const { data: decodedResult } = decodeJwt(signedJwt);
28+
assertOk<Jwt>(assert, decodedResult);
29+
const { data: signatureResult } = await hasValidSignature(decodedResult, publicJwks);
30+
assert.true(signatureResult);
2231
});
2332

2433
test('verifies the signature with a PEM formatted key', async assert => {
25-
assert.true(await hasValidSignature(decodeJwt(signedJwt), pemEncodedPublicKey));
34+
const { data: decodedResult } = decodeJwt(signedJwt);
35+
assertOk<Jwt>(assert, decodedResult);
36+
const { data: signatureResult } = await hasValidSignature(decodedResult, pemEncodedPublicKey);
37+
assert.true(signatureResult);
2638
});
2739

2840
test('it returns false if the key is not correct', async assert => {
29-
assert.false(await hasValidSignature(decodeJwt(signedJwt), someOtherPublicKey));
41+
const { data: decodedResult } = decodeJwt(signedJwt);
42+
assertOk<Jwt>(assert, decodedResult);
43+
const { data: signatureResult } = await hasValidSignature(decodedResult, someOtherPublicKey);
44+
assert.false(signatureResult);
3045
});
3146
});
3247

3348
module('decodeJwt(jwt)', () => {
3449
test('decodes a valid JWT', assert => {
35-
const { header, payload } = decodeJwt(mockJwt);
36-
assert.propEqual(header, mockJwtHeader);
37-
assert.propEqual(payload, mockJwtPayload);
38-
// TODO: @dimkl assert signature is instance of Uint8Array
50+
const { data } = decodeJwt(mockJwt);
51+
assertOk<Jwt>(assert, data);
52+
53+
assert.propEqual(data.header, mockJwtHeader);
54+
assert.propEqual(data.payload, mockJwtPayload);
55+
// TODO(@dimkl): assert signature is instance of Uint8Array
3956
});
4057

41-
test('throws an error if null is given as jwt', assert => {
42-
assert.throws(
43-
() => decodeJwt('null'),
44-
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
45-
);
58+
test('returns an error if null is given as jwt', assert => {
59+
const { error } = decodeJwt('null');
60+
assert.propContains(error, invalidTokenError);
4661
});
4762

48-
test('throws an error if undefined is given as jwt', assert => {
49-
assert.throws(
50-
() => decodeJwt('undefined'),
51-
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
52-
);
63+
test('returns an error if undefined is given as jwt', assert => {
64+
const { error } = decodeJwt('undefined');
65+
assert.propContains(error, invalidTokenError);
5366
});
5467

55-
test('throws an error if empty string is given as jwt', assert => {
56-
assert.throws(
57-
() => decodeJwt('undefined'),
58-
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
59-
);
68+
test('returns an error if empty string is given as jwt', assert => {
69+
const { error } = decodeJwt('');
70+
assert.propContains(error, invalidTokenError);
6071
});
6172

6273
test('throws an error if invalid string is given as jwt', assert => {
63-
assert.throws(
64-
() => decodeJwt('undefined'),
65-
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
66-
);
74+
const { error } = decodeJwt('whatever');
75+
assert.propContains(error, invalidTokenError);
6776
});
6877

6978
test('throws an error if number is given as jwt', assert => {
70-
assert.throws(
71-
() => decodeJwt('42'),
72-
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
73-
);
79+
const { error } = decodeJwt('42');
80+
assert.propContains(error, invalidTokenError);
7481
});
7582
});
7683

@@ -90,8 +97,8 @@ export default (QUnit: QUnit) => {
9097
issuer: mockJwtPayload.iss,
9198
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
9299
};
93-
const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions);
94-
assert.propEqual(payload, mockJwtPayload);
100+
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
101+
assert.propEqual(data, mockJwtPayload);
95102
});
96103

97104
test('returns the valid JWT payload if valid key & issuer method & azp is given', async assert => {
@@ -100,8 +107,8 @@ export default (QUnit: QUnit) => {
100107
issuer: (iss: string) => iss.startsWith('https://clerk'),
101108
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
102109
};
103-
const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions);
104-
assert.propEqual(payload, mockJwtPayload);
110+
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
111+
assert.propEqual(data, mockJwtPayload);
105112
});
106113

107114
test('returns the valid JWT payload if valid key & issuer & list of azp (with empty string) is given', async assert => {
@@ -110,12 +117,18 @@ export default (QUnit: QUnit) => {
110117
issuer: mockJwtPayload.iss,
111118
authorizedParties: ['', 'https://accounts.inspired.puma-74.lcl.dev'],
112119
};
113-
const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions);
114-
assert.propEqual(payload, mockJwtPayload);
120+
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
121+
assert.propEqual(data, mockJwtPayload);
115122
});
116123

117-
// todo('returns the reason of the failure when verifications fail', assert => {
118-
// assert.true(true);
119-
// });
124+
test('returns the reason of the failure when verifications fail', async assert => {
125+
const inputVerifyJwtOptions = {
126+
key: mockJwks.keys[0],
127+
issuer: mockJwtPayload.iss,
128+
authorizedParties: ['', 'https://accounts.inspired.puma-74.lcl.dev'],
129+
};
130+
const { error } = await verifyJwt('invalid-jwt', inputVerifyJwtOptions);
131+
assert.propContains(error, invalidTokenError);
132+
});
120133
});
121134
};

packages/backend/src/jwt/signJwt.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { SignJWTError } from '../errors';
12
import runtime from '../runtime';
23
import { base64url } from '../util/rfc4648';
34
import { getCryptoAlgorithm } from './algorithms';
45
import { importKey } from './cryptoKeys';
6+
import type { JwtReturnType } from './types';
57

68
export interface SignJwtOptions {
79
algorithm?: string;
@@ -32,7 +34,7 @@ export async function signJwt(
3234
payload: Record<string, unknown>,
3335
key: string | JsonWebKey,
3436
options: SignJwtOptions,
35-
): Promise<string> {
37+
): Promise<JwtReturnType<string, Error>> {
3638
if (!options.algorithm) {
3739
throw new Error('No algorithm specified');
3840
}
@@ -53,7 +55,11 @@ export async function signJwt(
5355
const encodedPayload = encodeJwtData(payload);
5456
const firstPart = `${encodedHeader}.${encodedPayload}`;
5557

56-
const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart));
57-
58-
return `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`;
58+
try {
59+
const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart));
60+
const encodedSignature = `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`;
61+
return { data: encodedSignature };
62+
} catch (error) {
63+
return { error: new SignJWTError((error as Error)?.message) };
64+
}
5965
}

packages/backend/src/jwt/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type JwtReturnType<R, E extends Error> =
2+
| {
3+
data: R;
4+
error?: undefined;
5+
}
6+
| {
7+
data?: undefined;
8+
error: E;
9+
};

0 commit comments

Comments
 (0)