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

feat: RedwoodJS Middleware to rate limit requests #1606

Draft
wants to merge 33 commits into
base: redwood
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e7cb060
WIP: basic functionality
dthyresson May 4, 2024
42e3169
Setup vitest
dthyresson May 4, 2024
b3d68f9
Delete apps/web/next-env.d.ts
dthyresson May 4, 2024
da515f5
Fix configs and tsconfig to properly import rw middleware
dthyresson May 6, 2024
1b6e655
get to green
dthyresson May 6, 2024
6e35a37
added some ratelimit mocking
dthyresson May 6, 2024
e71d942
Fixes custom error logic
dthyresson May 6, 2024
07d96a3
Support redwoodjs logger
dthyresson May 6, 2024
495e268
Added docs
dthyresson May 7, 2024
580db94
can rely on baked in middleware pattern route matching
dthyresson May 7, 2024
3be7995
ran fmt
dthyresson May 7, 2024
88af99c
remove path to exp
dthyresson May 7, 2024
59aa8b5
Adds redwoodjs library docs
dthyresson May 8, 2024
a1276ba
update readme
dthyresson May 8, 2024
0f5448a
rework so in future one middleware can be used for both ratelimiting …
dthyresson May 10, 2024
58a25cf
fix jsdoc example code
dthyresson May 10, 2024
b7a7a96
rename custom functions for simplicity
dthyresson May 10, 2024
5e49141
rename to withUnkeyConfig
dthyresson May 10, 2024
15f4040
WIP key auth middleware
dthyresson May 10, 2024
5e6d696
WIP auth key middleware
dthyresson May 10, 2024
4b9cf39
Refactor and rename
dthyresson May 11, 2024
2a0afd5
move logger and rename apikey using create
dthyresson May 11, 2024
2b5b316
add readme for key middleware
dthyresson May 11, 2024
ad04dd7
Reorganize file directory structure
dthyresson May 11, 2024
884db75
move tests
dthyresson May 11, 2024
d70379c
rename test blocks
dthyresson May 11, 2024
7ca2759
stub some tests
dthyresson May 11, 2024
4781fe4
commented
dthyresson May 11, 2024
3e23eaf
start testing createApiKeyMiddleware
dthyresson May 12, 2024
670ce90
add apikey middleware tests
dthyresson May 13, 2024
01d2e6f
adds docs for verify keys
dthyresson May 14, 2024
0689f0c
Upgrade redwood canary
dthyresson May 14, 2024
550c271
make both docs table of contents same
dthyresson May 14, 2024
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
25 changes: 18 additions & 7 deletions packages/redwoodjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,33 @@
"publishConfig": {
"access": "public"
},
"keywords": ["unkey", "redwoodjs", "sdk"],
"keywords": [
"unkey",
"redwoodjs",
"sdk"
],
"bugs": {
"url": "https://github.com/unkeyed/unkey/issues"
},
"homepage": "https://github.com/unkeyed/unkey#readme",
"files": ["./dist/**"],
"files": [
"./dist/**"
],
"author": "David Thyresson",
"scripts": {
"build": "tsup"
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
"@redwoodjs/vite": "8.0.0-canary.542",
"@unkey/ratelimit": "workspace:^",
"path-to-regexp": "^6.2.2"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@unkey/tsconfig": "workspace:^",
"tsup": "^8.0.2",
"typescript": "^5.3.3"
},
"dependencies": {
"@unkey/api": "workspace:^"
"typescript": "^5.3.3",
"vitest": "^1.6.0"
}
}
14 changes: 1 addition & 13 deletions packages/redwoodjs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1 @@
import { Unkey } from "@unkey/api";

import { version } from "../package.json";

export async function doTheMagicHere() {
const _unkey = new Unkey({
rootKey: "GET_THIS_FROM_ENV_SOMEHOW",
wrapperSdkVersion: `@unkey/redwoodjs@${version}`,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chronark I noticed that the rate limit sdk didn't have an option to add the version like the main Unkey api did.

Do you still want to include the telemetry option as well?

disableTelemetry: false, // TODO: andreas
});

// do stuff :)
}
export * from "./ratelimit";
87 changes: 87 additions & 0 deletions packages/redwoodjs/src/ratelimit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# withUnkey

RedwoodJS Middleware to rate limit requests

## Setup

See [Rate limiting Onboarding](https://www.unkey.com/docs/onboarding/onboarding-ratelimiting) to get started with standalone [rate limiting](https://www.unkey.com/docs/apis/features/ratelimiting) from [Unkey](https://www.unkey.com).

Note: Be sure to set your `UNKEY_ROOT_KEY` or key to be used for rate limiting in an `.env` file.

## Examples

### With Third Party Authentication like Supabase

Here, we use a custom identifier function `supabaseRatelimitIdentifier` that:

- checks is the request is authenticated
- constructs the identifier `sub` from the current user, since here the currentUser will be a JWT where the user id is the `sub` claim
- registers `supabaseAuthMiddleware` before `unkeyMiddleware` so the request can be authenticated before determining limits

```file="web/entry.server.ts"
import createSupabaseAuthMiddleware from '@redwoodjs/auth-supabase-middleware'
import type { MiddlewareRequest } from '@redwoodjs/vite/middleware'
import type { TagDescriptor } from '@redwoodjs/web'

import App from './App'
import { Document } from './Document'
import withUnkey from '@unkey/redwoodjs'
import type { withUnkeyOptions } from '@unkey/redwoodjs'

// eslint-disable-next-line no-restricted-imports
import { getCurrentUser } from '$api/src/lib/auth'

interface Props {
css: string[]
meta?: TagDescriptor[]
}

export const supabaseRatelimitIdentifier = (req: MiddlewareRequest) => {
const authContext = req?.serverAuthContext?.get()
console.log('>>>> in supabaseRatelimitIdentifier', authContext)
const identifier = authContext?.isAuthenticated
? (authContext.currentUser?.sub as string) || 'anonymous-user'
: '192.168.1.1'
return identifier
}

export const registerMiddleware = () => {
const options: withUnkeyOptions = {
ratelimitConfig: {
rootKey: process.env.UNKEY_ROOT_KEY,
namespace: 'my-app',
limit: 1,
duration: '30s',
async: true,
},
matcher: ['/blog-post/:slug(\\d{1,})'],
ratelimitIdentifierFn: supabaseRatelimitIdentifier,
}
const unkeyMiddleware = withUnkey(options)
const supabaseAuthMiddleware = createSupabaseAuthMiddleware({
getCurrentUser,
})
return [supabaseAuthMiddleware, unkeyMiddleware]
}

interface Props {
css: string[]
meta?: TagDescriptor[]
}

export const ServerEntry: React.FC<Props> = ({ css, meta }) => {
return (
<Document css={css} meta={meta}>
<App />
</Document>
)
}
```

## Custom Rate Limit Exceeded Response

TODO

## Custom Rate Limit Error Response

TODO
27 changes: 27 additions & 0 deletions packages/redwoodjs/src/ratelimit/__tests__/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { MiddlewareRequest } from "@redwoodjs/vite/middleware";
import { assert, describe, expect, it } from "vitest";
import { defaultRatelimitIdentifier, matchesPath } from "../util";

describe("defaultRatelimitIdentifier", () => {
it("should return correct identifier", () => {
// Your test logic here
const req = {} as MiddlewareRequest;
assert.equal(defaultRatelimitIdentifier(req), "192.168.1.1");
});
});

// describe("matchesPath", () => {
// it("should return true if path matches the pattern", () => {
// const path = "/api/user";
// const pattern = "/api/*";
// const result = matchesPath(path, pattern);
// expect(result).toBe(true);
// });

// it("should return false if path does not match the pattern", () => {
// const path = "/api/user";
// const pattern = "/admin/*";
// const result = matchesPath(path, pattern);
// expect(result).toBe(false);
// });
// });
79 changes: 79 additions & 0 deletions packages/redwoodjs/src/ratelimit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//import { Unkey } from "@unkey/api";
//import { version } from "../../package.json";

import { Ratelimit } from "@unkey/ratelimit";
import type { RatelimitConfig } from "@unkey/ratelimit";

import type { MiddlewareRequest } from "@redwoodjs/vite/middleware";
import type { MiddlewareResponse } from "@redwoodjs/vite/middleware";

import {
defaultRatelimitErrorResponse,
defaultRatelimitExceededResponse,
defaultRatelimitIdentifier,
matchesPath,
} from "./util";
import type { MiddlewarePathMatcher } from "./util";

export type withUnkeyOptions = {
ratelimitConfig: RatelimitConfig;
matcher: MiddlewarePathMatcher;
ratelimitIdentifierFn?: (req: MiddlewareRequest) => string;
ratelimitExceededResponseFn?: (req: MiddlewareRequest) => MiddlewareResponse;
ratelimitErrorResponseFn?: (req: MiddlewareRequest) => MiddlewareResponse;
};

const withUnkey = (options: withUnkeyOptions) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments and console removal to come.

console.debug(">>>> in withUnkey createMiddleware", options);
const unkey = new Ratelimit(options.ratelimitConfig);

return async (req: MiddlewareRequest, res: MiddlewareResponse) => {
const ratelimitIdentifier = options.ratelimitIdentifierFn || defaultRatelimitIdentifier;

const rateLimitExceededResponse =
options.ratelimitExceededResponseFn || defaultRatelimitExceededResponse;

const rateLimitErrorResponse =
options.ratelimitErrorResponseFn || defaultRatelimitErrorResponse;

try {
const url = new URL(req.url);
const path = url.pathname;

if (!matchesPath(path, options.matcher)) {
console.debug(">>>> in withUnkey skip middleware for", req.url);
return res;
}

const identifier = ratelimitIdentifier(req);

console.debug(">>>> in withUnkey identifier", identifier);
const ratelimit = await unkey.limit(identifier);

if (!ratelimit.success) {
console.error("Rate limit exceeded", ratelimit);
const response = rateLimitExceededResponse(req);
if (response.status !== 429) {
console.warn("Rate limit exceeded response is not 429. Overriding status.", response);
response.status = 429;
}
return response;
}
} catch (e) {
console.error("Error in withUnkey", e);
const response = rateLimitErrorResponse(req);
if (response.status === 500) {
console.warn(
"Rate limit error response is 200 OK. Consider changing status to 500.",
response,
);
}
}

console.debug(">>>> in withUnkey return response for", req.url);

return res;
};
};

export default withUnkey;
37 changes: 37 additions & 0 deletions packages/redwoodjs/src/ratelimit/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type MatchFunction, match } from "path-to-regexp";

import type { MiddlewareRequest } from "@redwoodjs/vite/middleware";
import { MiddlewareResponse } from "@redwoodjs/vite/middleware";

export type MiddlewarePathMatcher = string | string[];

export const defaultRatelimitIdentifier = (req: MiddlewareRequest) => {
const authContext = req?.serverAuthContext?.get();
const identifier = authContext?.isAuthenticated
? Buffer.from(JSON.stringify(authContext.currentUser)).toString("base64")
: "192.168.1.1";
return identifier;
};

export const matchesPath = (path: string, matcher: MiddlewarePathMatcher): boolean => {
// Convert matcher to an array if it's not already one
const matchers = Array.isArray(matcher) ? matcher : [matcher];

console.debug(">>>> in matchesPath", matchers, path);

// Create a list of matching functions from the matchers
const matchingFunctions: MatchFunction[] = matchers.map((pattern) =>
match(pattern, { decode: decodeURIComponent }),
);

// Check if the path matches any of the patterns
return matchingFunctions.some((matchFunc) => matchFunc(path) !== false);
};

export const defaultRatelimitExceededResponse = (_req: MiddlewareRequest) => {
return new MiddlewareResponse("Rate limit exceeded", { status: 429 });
};

export const defaultRatelimitErrorResponse = (_req: MiddlewareRequest) => {
return new MiddlewareResponse("Internal server error", { status: 500 });
};
12 changes: 5 additions & 7 deletions packages/redwoodjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "@unkey/tsconfig/base.json",
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
Expand All @@ -10,11 +11,8 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"ESNext",
"DOM"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "preserve", /* Specify what JSX code is generated. */
"lib": ["ESNext", "DOM"],
/* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
Expand All @@ -25,9 +23,9 @@
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
// "module": "CommonJS", /* Specify what module code is generated. */
"module": "NodeNext" /* Specify what module code is generated. */,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed as redwoodjs middleware is NodeNext.

// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"moduleResolution": "NodeNext" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
Expand Down
4 changes: 4 additions & 0 deletions packages/redwoodjs/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to make this a .mts file otherwise thought was CommonJS and TS was sad.


export default defineConfig({});
Loading