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

chore(*): Improve @clerk/backend DX [Part 6 - token and jwt utils return values] #2377

Merged
merged 3 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions .changeset/dull-ants-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@clerk/backend': major
---

Change return value of `verifyToken()` from `@clerk/backend` to `{ data, error}`.
To replicate the current behaviour use this:
```typescript
import { verifyToken } from '@clerk/backend'

const { data, error } = await verifyToken(...);
if(error){
throw error;
}
```
5 changes: 5 additions & 0 deletions .changeset/mighty-rice-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/types': minor
---

Introduce new `ResultWithError` type in `@clerk/types`
23 changes: 23 additions & 0 deletions .changeset/proud-trees-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@clerk/backend': major
'@clerk/nextjs': major
'@clerk/types': major
---

Change return values of `signJwt`, `hasValidSignature`, `decodeJwt`, `verifyJwt`
to return `{ data, error }`. Example of keeping the same behavior using those utilities:
```typescript
import { signJwt, hasValidSignature, decodeJwt, verifyJwt } from '@clerk/backend/jwt';

const { data, error } = await signJwt(...)
if (error) throw error;

const { data, error } = await hasValidSignature(...)
if (error) throw error;

const { data, error } = decodeJwt(...)
if (error) throw error;

const { data, error } = await verifyJwt(...)
if (error) throw error;
```
25 changes: 12 additions & 13 deletions packages/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates).
```js
import { verifyToken } from '@clerk/backend';

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

```js
import { verifyJwt } from '@clerk/backend';
import { verifyJwt } from '@clerk/backend/jwt';

verifyJwt(token, {
const { result, error } = verifyJwt(token, {
key: JsonWebKey | string,
issuer: '...',
authorizedParties: '...',
});
```
Expand All @@ -128,27 +127,27 @@ verifyJwt(token, {
Decodes a JWT.

```js
import { decodeJwt } from '@clerk/backend';
import { decodeJwt } from '@clerk/backend/jwt';

decodeJwt(token);
const { result, error } = decodeJwt(token);
```

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

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

```js
import { hasValidSignature } from '@clerk/backend';
import { hasValidSignature } from '@clerk/backend/jwt';

hasValidSignature(token, jwk);
const { result, error } = await hasValidSignature(token, jwk);
```

#### debugRequestState(requestState)

Generates a debug payload for the request state

```js
import { debugRequestState } from '@clerk/backend';
import { debugRequestState } from '@clerk/backend/internal';

debugRequestState(requestState);
```
Expand All @@ -158,7 +157,7 @@ debugRequestState(requestState);
Builds the AuthObject when the user is signed in.

```js
import { signedInAuthObject } from '@clerk/backend';
import { signedInAuthObject } from '@clerk/backend/internal';

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

```js
import { signedOutAuthObject } from '@clerk/backend';
import { signedOutAuthObject } from '@clerk/backend/internal';

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

```js
import { sanitizeAuthObject } from '@clerk/backend';
import { sanitizeAuthObject } from '@clerk/backend/internal';

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

```js
import { prunePrivateMetadata } from '@clerk/backend';
import { prunePrivateMetadata } from '@clerk/backend/internal';

prunePrivateMetadata(obj);
```
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default (QUnit: QUnit) => {
module('subpath /errors exports', () => {
test('should not include a breaking change', assert => {
const exportedApiKeys = [
'SignJWTError',
'TokenVerificationError',
'TokenVerificationErrorAction',
'TokenVerificationErrorCode',
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,5 @@ export class TokenVerificationError extends Error {
})`;
}
}

export class SignJWTError extends Error {}
11 changes: 7 additions & 4 deletions packages/backend/src/jwt/__tests__/signJwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
publicJwks,
signingJwks,
} from '../../fixtures';
import { assertOk } from '../../util/testUtils';
import { signJwt } from '../signJwt';
import { verifyJwt } from '../verifyJwt';

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

test('signs a JWT with a JWK formatted secret', async assert => {
const jwt = await signJwt(payload, signingJwks, {
const { data } = await signJwt(payload, signingJwks, {
algorithm: mockJwtHeader.alg,
header: mockJwtHeader,
});
assertOk(assert, data);

const verifiedPayload = await verifyJwt(jwt, { key: publicJwks });
const { data: verifiedPayload } = await verifyJwt(data, { key: publicJwks });
assert.deepEqual(verifiedPayload, payload);
});

test('signs a JWT with a pkcs8 formatted secret', async assert => {
const jwt = await signJwt(payload, pemEncodedSignKey, {
const { data } = await signJwt(payload, pemEncodedSignKey, {
algorithm: mockJwtHeader.alg,
header: mockJwtHeader,
});
assertOk(assert, data);

const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey });
const { data: verifiedPayload } = await verifyJwt(data, { key: pemEncodedPublicKey });
assert.deepEqual(verifiedPayload, payload);
});
});
Expand Down
91 changes: 52 additions & 39 deletions packages/backend/src/jwt/__tests__/verifyJwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Jwt } from '@clerk/types';
import type QUnit from 'qunit';
import sinon from 'sinon';

Expand All @@ -11,66 +12,72 @@ import {
signedJwt,
someOtherPublicKey,
} from '../../fixtures';
import { assertOk } from '../../util/testUtils';
import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt';

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

module('hasValidSignature(jwt, key)', () => {
test('verifies the signature with a JWK formatted key', async assert => {
assert.true(await hasValidSignature(decodeJwt(signedJwt), publicJwks));
const { data: decodedResult } = decodeJwt(signedJwt);
assertOk<Jwt>(assert, decodedResult);
const { data: signatureResult } = await hasValidSignature(decodedResult, publicJwks);
assert.true(signatureResult);
});

test('verifies the signature with a PEM formatted key', async assert => {
assert.true(await hasValidSignature(decodeJwt(signedJwt), pemEncodedPublicKey));
const { data: decodedResult } = decodeJwt(signedJwt);
assertOk<Jwt>(assert, decodedResult);
const { data: signatureResult } = await hasValidSignature(decodedResult, pemEncodedPublicKey);
assert.true(signatureResult);
});

test('it returns false if the key is not correct', async assert => {
assert.false(await hasValidSignature(decodeJwt(signedJwt), someOtherPublicKey));
const { data: decodedResult } = decodeJwt(signedJwt);
assertOk<Jwt>(assert, decodedResult);
const { data: signatureResult } = await hasValidSignature(decodedResult, someOtherPublicKey);
assert.false(signatureResult);
});
});

module('decodeJwt(jwt)', () => {
test('decodes a valid JWT', assert => {
const { header, payload } = decodeJwt(mockJwt);
assert.propEqual(header, mockJwtHeader);
assert.propEqual(payload, mockJwtPayload);
// TODO: @dimkl assert signature is instance of Uint8Array
const { data } = decodeJwt(mockJwt);
assertOk<Jwt>(assert, data);

assert.propEqual(data.header, mockJwtHeader);
assert.propEqual(data.payload, mockJwtPayload);
// TODO(@dimkl): assert signature is instance of Uint8Array
});

test('throws an error if null is given as jwt', assert => {
assert.throws(
() => decodeJwt('null'),
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
);
test('returns an error if null is given as jwt', assert => {
const { error } = decodeJwt('null');
assert.propContains(error, invalidTokenError);
});

test('throws an error if undefined is given as jwt', assert => {
assert.throws(
() => decodeJwt('undefined'),
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
);
test('returns an error if undefined is given as jwt', assert => {
const { error } = decodeJwt('undefined');
assert.propContains(error, invalidTokenError);
});

test('throws an error if empty string is given as jwt', assert => {
assert.throws(
() => decodeJwt('undefined'),
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
);
test('returns an error if empty string is given as jwt', assert => {
const { error } = decodeJwt('');
assert.propContains(error, invalidTokenError);
});

test('throws an error if invalid string is given as jwt', assert => {
assert.throws(
() => decodeJwt('undefined'),
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
);
const { error } = decodeJwt('whatever');
assert.propContains(error, invalidTokenError);
});

test('throws an error if number is given as jwt', assert => {
assert.throws(
() => decodeJwt('42'),
new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'),
);
const { error } = decodeJwt('42');
assert.propContains(error, invalidTokenError);
});
});

Expand All @@ -90,8 +97,8 @@ export default (QUnit: QUnit) => {
issuer: mockJwtPayload.iss,
authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'],
};
const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions);
assert.propEqual(payload, mockJwtPayload);
const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions);
assert.propEqual(data, mockJwtPayload);
});

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

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

// todo('returns the reason of the failure when verifications fail', assert => {
// assert.true(true);
// });
test('returns the reason of the failure when verifications fail', async assert => {
const inputVerifyJwtOptions = {
key: mockJwks.keys[0],
issuer: mockJwtPayload.iss,
authorizedParties: ['', 'https://accounts.inspired.puma-74.lcl.dev'],
};
const { error } = await verifyJwt('invalid-jwt', inputVerifyJwtOptions);
assert.propContains(error, invalidTokenError);
});
});
};
14 changes: 10 additions & 4 deletions packages/backend/src/jwt/signJwt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { SignJWTError } from '../errors';
import runtime from '../runtime';
import { base64url } from '../util/rfc4648';
import { getCryptoAlgorithm } from './algorithms';
import { importKey } from './cryptoKeys';
import type { JwtReturnType } from './types';

export interface SignJwtOptions {
algorithm?: string;
Expand Down Expand Up @@ -32,7 +34,7 @@ export async function signJwt(
payload: Record<string, unknown>,
key: string | JsonWebKey,
options: SignJwtOptions,
): Promise<string> {
): Promise<JwtReturnType<string, Error>> {
if (!options.algorithm) {
throw new Error('No algorithm specified');
}
Expand All @@ -53,7 +55,11 @@ export async function signJwt(
const encodedPayload = encodeJwtData(payload);
const firstPart = `${encodedHeader}.${encodedPayload}`;

const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart));

return `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`;
try {
const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart));
const encodedSignature = `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`;
return { data: encodedSignature };
} catch (error) {
return { error: new SignJWTError((error as Error)?.message) };
}
}
9 changes: 9 additions & 0 deletions packages/backend/src/jwt/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type JwtReturnType<R, E extends Error> =
| {
data: R;
error?: undefined;
}
| {
data?: undefined;
error: E;
};
Loading
Loading