Skip to content

Commit

Permalink
feat(backend-core,clerk-sdk-node,edge): Add support to verify azp ses…
Browse files Browse the repository at this point in the history
…sion token claim

Add support on VerifyToken and across package middlewares to verify the azp claim of the session token against a supplied whitelist of authorized parties
  • Loading branch information
chanioxaris committed Feb 2, 2022
1 parent 793bdb8 commit eab1c8c
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 16 deletions.
9 changes: 9 additions & 0 deletions packages/backend-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ examplePlatformBase.getAuthState(...);

The `Base` utilities include the building blocks for developing any extra logic and middleware required for the target platform.

### Validate the Authorized Party of a session token
Clerk's JWT session token, contains the azp claim, which equals the Origin of the request during token generation. You can provide a list of whitelisted origins to verify against, during every token verification, to protect your application of the subdomain cookie leaking attack. You can find an example below:

```ts
const authorizedParties = ['http://localhost:3000', 'https://example.com']

examplePlatformBase.verifySessionToken(token> { authorizedParties });
```

### Clerk API Resources

API resource management is also provided by this package through the [ClerkBackendAPI](./src/api/ClerkBackendAPI.ts) class. For more information on the API resources you can checkout the [resource documentation page](https://docs.clerk.dev/reference/backend-api-reference).
Expand Down
20 changes: 16 additions & 4 deletions packages/backend-core/src/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export type Session = {
userId?: string;
};

export type VerifySessionTokenOptions = {
authorizedParties?: string[];
}

const verifySessionTokenDefaultOptions: VerifySessionTokenOptions = {
authorizedParties: [],
}

type AuthState = {
status: AuthStatus;
session?: Session;
Expand All @@ -53,6 +61,8 @@ type AuthStateParams = {
referrer?: string | null;
/* Request user-agent value */
userAgent?: string | null;
/* A list of authorized parties to validate against the session token azp claim */
authorizedParties?: string[];
/* HTTP utility for fetching a text/html string */
fetchInterstitial: () => Promise<string>;
};
Expand Down Expand Up @@ -89,10 +99,11 @@ export class Base {
* The public key will be supplied in the form of CryptoKey or will be loaded from the CLERK_JWT_KEY environment variable.
*
* @param {string} token
* @param {VerifySessionTokenOptions} verifySessionTokenOptions
* @return {Promise<JWTPayload>} claims
* @throws {JWTExpiredError|Error}
*/
verifySessionToken = async (token: string): Promise<JWTPayload> => {
verifySessionToken = async (token: string, { authorizedParties }: VerifySessionTokenOptions = verifySessionTokenDefaultOptions): Promise<JWTPayload> => {
// Try to load the PK from supplied function and
// if there is no custom load function
// try to load from the environment.
Expand All @@ -101,7 +112,7 @@ export class Base {
: await this.loadCryptoKeyFromEnv();

const claims = await this.verifyJwt(availableKey, token);
checkClaims(claims);
checkClaims(claims, authorizedParties);
return claims;
};

Expand Down Expand Up @@ -209,6 +220,7 @@ export class Base {
forwardedPort,
referrer,
userAgent,
authorizedParties,
fetchInterstitial,
}: AuthStateParams): Promise<AuthState> => {
const isCrossOrigin = checkCrossOrigin(
Expand All @@ -221,7 +233,7 @@ export class Base {
let sessionClaims;
if (headerToken) {
try {
sessionClaims = await this.verifySessionToken(headerToken);
sessionClaims = await this.verifySessionToken(headerToken, { authorizedParties });
return {
status: AuthStatus.SignedIn,
session: {
Expand Down Expand Up @@ -306,7 +318,7 @@ export class Base {
}

try {
sessionClaims = await this.verifySessionToken(cookieToken as string);
sessionClaims = await this.verifySessionToken(cookieToken as string, { authorizedParties });
} catch (err) {
if (err instanceof JWTExpiredError) {
return {
Expand Down
87 changes: 87 additions & 0 deletions packages/backend-core/src/__tests__/utils/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { checkClaims } from '../../util/jwt';
import { JWTPayload } from '../../util/types';

test('check jwt claims with no issuer', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
}

expect(() => checkClaims(dummyClaims)).toThrow(`Issuer is invalid: ${dummyClaims.iss}`)
})

test('check jwt claims with invalid issuer', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'invalid-issuer',
}

expect(() => checkClaims(dummyClaims)).toThrow(`Issuer is invalid: ${dummyClaims.iss}`)
})

test('check jwt claims with valid issuer', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
}

expect(() => checkClaims(dummyClaims)).not.toThrow()
})

test('check jwt claims with invalid azp', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
azp: 'invalid-azp',
}
const authorizedParties: string[] = ['valid-azp', 'another-valid-azp']

expect(() => checkClaims(dummyClaims, authorizedParties)).toThrow(`Authorized party is invalid: ${dummyClaims.azp}`)
})

test('check jwt claims with no azp and no authorized parties', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
}

expect(() => checkClaims(dummyClaims)).not.toThrow()
})

test('check jwt claims with no azp and provided authorized parties', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
}
const authorizedParties: string[] = ['valid-azp', 'another-valid-azp']

expect(() => checkClaims(dummyClaims, authorizedParties)).not.toThrow()
})

test('check jwt claims with azp and no authorized parties', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
azp: 'random-azp',
}

expect(() => checkClaims(dummyClaims)).not.toThrow()
})

test('check jwt claims with no azp and provided authorized parties', () => {
const dummyClaims: JWTPayload = {
sub: 'subject',
exp: 1643374283,
iss: 'https://clerk.happy.path',
azp: 'valid-azp',
}
const authorizedParties: string[] = ['a-valid-azp', 'valid-azp', 'another-valid-azp']

expect(() => checkClaims(dummyClaims, authorizedParties)).not.toThrow()
})
8 changes: 7 additions & 1 deletion packages/backend-core/src/util/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ export function isExpired(
}
}

export function checkClaims(claims: JWTPayload) {
export function checkClaims(claims: JWTPayload, authorizedParties?: string[]) {
if (!claims.iss || !claims.iss.startsWith('https://clerk')) {
throw new Error(`Issuer is invalid: ${claims.iss}`);
}

if (claims.azp && authorizedParties && authorizedParties.length > 0) {
if (!authorizedParties.includes(claims.azp as string)) {
throw new Error(`Authorized party is invalid: ${claims.azp}`);
}
}
}
5 changes: 5 additions & 0 deletions packages/backend-core/src/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface JWTPayload {
*/
iat?: number;

/**
* JWT Authorized party - [RFC7800#section-3](https://tools.ietf.org/html/rfc7800#section-3).
*/
azp?: string;

/**
* Any other JWT Claim Set member.
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/edge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,18 @@ Supported methods:
- `withSession`
- `verifySessionToken`
- Resources API through `ClerkAPI`

### Validate the Authorized Party of a session token
Clerk's JWT session token, contains the azp claim, which equals the Origin of the request during token generation. You can provide the middlewares with a list of whitelisted origins to verify against, to protect your application of the subdomain cookie leaking attack. You can find an example below:

```ts
import { withSession } from '@clerk/edge/vercel-edge';

const authorizedParties = ['http://localhost:3000', 'https://example.com']

async function handler(req, event) {
// ...
}

export const middleware = withSession(handler, { authorizedParties });
```
7 changes: 6 additions & 1 deletion packages/edge/src/vercel-edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ async function fetchInterstitial() {

export type NextRequestWithSession = NextRequest & { session: Session };

export function withSession(handler: Middleware) {
export type MiddlewareOptions = {
authorizedParties?: string[];
};

export function withSession(handler: Middleware, { authorizedParties }: MiddlewareOptions = { authorizedParties: []}) {
return async function clerkAuth(req: NextRequest, event: NextFetchEvent) {
const { status, session, interstitial } = await vercelEdgeBase.getAuthState(
{
Expand All @@ -88,6 +92,7 @@ export function withSession(handler: Middleware) {
forwardedPort: req.headers.get('x-forwarded-port'),
forwardedHost: req.headers.get('x-forwarded-host'),
referrer: req.headers.get('referrer'),
authorizedParties: authorizedParties,
fetchInterstitial,
}
);
Expand Down
25 changes: 25 additions & 0 deletions packages/sdk-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,31 @@ export clerk.withSession(handler);
export clerk.requireSession(handler);
```

## Validate the Authorized Party of a session token
Clerk's JWT session token, contains the azp claim, which equals the Origin of the request during token generation. You can provide the middlewares with a list of whitelisted origins to verify against, to protect your application of the subdomain cookie leaking attack. You can find an example below:

### Express

```ts
import { ClerkExpressRequireSession } from '@clerk/clerk-sdk-node';

const authorizedParties = ['http://localhost:3000', 'https://example.com']

app.use(ClerkExpressRequireSession({ authorizedParties }));
```

### Next

```ts
const authorizedParties = ['http://localhost:3000', 'https://example.com']

function handler(req: RequireSessionProp<NextApiRequest>, res: NextApiResponse) {
// do something with session.userId
}

export requireSession(handler, { authorizedParties });
```

## Troubleshooting

Especially when using the middlewares, a number of common issues may occur.
Expand Down
28 changes: 18 additions & 10 deletions packages/sdk-node/src/Clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const packageRepo = 'https://github.com/clerkinc/clerk-sdk-node';

export type MiddlewareOptions = {
onError?: Function;
authorizedParties?: string[];
};

export type WithSessionProp<T> = T & { session?: Session };
Expand Down Expand Up @@ -154,7 +155,7 @@ export default class Clerk extends ClerkBackendAPI {
);
}

async verifyToken(token: string): Promise<JwtPayload> {
async verifyToken(token: string, authorizedParties?: string[]): Promise<JwtPayload> {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error(`Failed to verify token: ${token}`);
Expand All @@ -169,8 +170,14 @@ export default class Clerk extends ClerkBackendAPI {
throw new Error('Malformed token');
}

if (!(verified.iss?.lastIndexOf('https://clerk.', 0) === 0)) {
throw new Error(`Invalid issuer: ${verified.iss}`);
if (!verified.iss || !verified.iss.startsWith('https://clerk')) {
throw new Error(`Issuer is invalid: ${verified.iss}`);
}

if (verified.azp && authorizedParties && authorizedParties.length > 0) {
if (!authorizedParties.includes(verified.azp as string)) {
throw new Error(`Authorized party is invalid: ${verified.azp}`);
}
}

return verified;
Expand Down Expand Up @@ -208,7 +215,7 @@ export default class Clerk extends ClerkBackendAPI {
}

expressWithSession(
{ onError }: MiddlewareOptions = { onError: this.defaultOnError }
{ onError, authorizedParties }: MiddlewareOptions = { onError: this.defaultOnError }
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
function signedOut() {
throw new Error('Unauthenticated');
Expand All @@ -234,6 +241,7 @@ export default class Clerk extends ClerkBackendAPI {
forwardedHost: req.headers['x-forwarded-host'] as string,
referrer: req.headers.referer,
userAgent: req.headers['user-agent'] as string,
authorizedParties: authorizedParties,
fetchInterstitial: () => this.fetchInterstitial(),
});

Expand Down Expand Up @@ -274,9 +282,9 @@ export default class Clerk extends ClerkBackendAPI {
}

expressRequireSession(
{ onError }: MiddlewareOptions = { onError: this.strictOnError }
{ onError, authorizedParties }: MiddlewareOptions = { onError: this.strictOnError }
) {
return this.expressWithSession({ onError });
return this.expressWithSession({ onError, authorizedParties });
}

// Credits to https://nextjs.org/docs/api-routes/api-middlewares
Expand All @@ -299,7 +307,7 @@ export default class Clerk extends ClerkBackendAPI {
// Set the session on the request and then call provided handler
withSession(
handler: Function,
{ onError }: MiddlewareOptions = { onError: this.defaultOnError }
{ onError, authorizedParties }: MiddlewareOptions = { onError: this.defaultOnError }
) {
return async (
req: WithSessionProp<Request> | WithSessionClaimsProp<Request>,
Expand All @@ -310,7 +318,7 @@ export default class Clerk extends ClerkBackendAPI {
await this._runMiddleware(
req,
res,
this.expressWithSession({ onError })
this.expressWithSession({ onError, authorizedParties })
);
return handler(req, res, next);
} catch (error) {
Expand All @@ -331,8 +339,8 @@ export default class Clerk extends ClerkBackendAPI {
// Stricter version, short-circuits if session can't be determined
requireSession(
handler: Function,
{ onError }: MiddlewareOptions = { onError: this.strictOnError }
{ onError, authorizedParties }: MiddlewareOptions = { onError: this.strictOnError }
) {
return this.withSession(handler, { onError });
return this.withSession(handler, { onError, authorizedParties });
}
}

0 comments on commit eab1c8c

Please sign in to comment.