@japikey/cloudflare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/eslint.config.js +3 -0
- package/package.json +59 -0
- package/src/d1.ts +185 -0
- package/src/index.ts +12 -0
- package/src/router.ts +280 -0
- package/test/index.test.ts +14 -0
- package/test/router.test.ts +1120 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# @japikey/cloudflare
|
|
2
|
+
|
|
3
|
+
This package generates Cloudflare worker routes and middleware to fully integrate japikeys into your app. You can create api keys, view, or delete them, and validate they are valid.
|
|
4
|
+
This package is part of the larger @japikey ecosystem. See @japikey/japikey for more information.
|
|
5
|
+
|
|
6
|
+
# Prerequisites
|
|
7
|
+
|
|
8
|
+
Have an existing Cloudflare Worker application that has some other mechanism for authentication. When a user creates an API key, we only want to grant access for that user, after all! You'll also need a D1 database to store the API key information.
|
|
9
|
+
|
|
10
|
+
# Installing japikey
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install --save "@japikey/japikey"
|
|
14
|
+
npm install --save "@japikey/cloudflare"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
# Setting up your Worker
|
|
18
|
+
|
|
19
|
+
Create a standard Cloudflare Worker that handles API key routes and JWKS endpoints:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { D1Driver, UnauthorizedError } from '@japikey/cloudflare';
|
|
23
|
+
import {
|
|
24
|
+
createApiKeyRouter,
|
|
25
|
+
createJWKSRouter,
|
|
26
|
+
isJWKSPath,
|
|
27
|
+
type CreateApiKeyRouterOptions,
|
|
28
|
+
type CreateApiKeyData,
|
|
29
|
+
} from '@japikey/cloudflare';
|
|
30
|
+
|
|
31
|
+
interface Env {
|
|
32
|
+
DB: D1Database;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getUserId(request: Request, env: Env): Promise<string> {
|
|
36
|
+
// This should integrate with your existing auth system
|
|
37
|
+
// For example, you might check for a session cookie or JWT token
|
|
38
|
+
// Example: Check for a user session
|
|
39
|
+
const session = request.headers.get('Authorization');
|
|
40
|
+
if (!session) {
|
|
41
|
+
throw new UnauthorizedError();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate the session and extract user ID
|
|
45
|
+
// This is where you'd integrate with your auth system
|
|
46
|
+
const userId = await validateSession(session);
|
|
47
|
+
if (!userId) {
|
|
48
|
+
throw new UnauthorizedError();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return userId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function parseCreateApiKeyRequest(
|
|
55
|
+
request: Request,
|
|
56
|
+
env: Env
|
|
57
|
+
): Promise<CreateApiKeyData> {
|
|
58
|
+
const body = await request.json();
|
|
59
|
+
const { expiresAt, scopes } = body;
|
|
60
|
+
|
|
61
|
+
// Add extra validation as desired
|
|
62
|
+
return {
|
|
63
|
+
expiresAt: new Date(expiresAt),
|
|
64
|
+
claims: { scopes }, // Any claims you want encoded in the token
|
|
65
|
+
databaseMetadata: {}, // Any extra things you want to store in the database
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default {
|
|
70
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
71
|
+
const url = new URL(request.url);
|
|
72
|
+
|
|
73
|
+
// Handle API key routes
|
|
74
|
+
if (url.pathname.startsWith('/api-keys')) {
|
|
75
|
+
const db = new D1Driver(env.DB);
|
|
76
|
+
await db.ensureTable();
|
|
77
|
+
|
|
78
|
+
const options: CreateApiKeyRouterOptions<Env> = {
|
|
79
|
+
getUserId,
|
|
80
|
+
parseCreateApiKeyRequest,
|
|
81
|
+
issuer: new URL('https://example.com/'),
|
|
82
|
+
aud: 'api-key',
|
|
83
|
+
db,
|
|
84
|
+
routePrefix: '/api-keys',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const apiKeyRouter = createApiKeyRouter(options);
|
|
88
|
+
return apiKeyRouter.fetch(request, env);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle JWKS routes
|
|
92
|
+
const baseIssuer = new URL('https://example.com/');
|
|
93
|
+
if (isJWKSPath(request, baseIssuer)) {
|
|
94
|
+
const jwksRouter = createJWKSRouter(baseIssuer, db);
|
|
95
|
+
return jwksRouter.fetch(request, env);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle other routes
|
|
99
|
+
return new Response('Not found', { status: 404 });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Details about the configuration
|
|
105
|
+
|
|
106
|
+
There are two separate endpoint banks to implement. The apiKeyRouter handles the CRUD operations to create, list, and delete an api key.
|
|
107
|
+
|
|
108
|
+
The jwksRouter handles any client that wants to validate the API key. It provides the public key data via the `.well-known/jwks.json` endpoint. These two endpoints do need to be configured in a compatible way, specifically around the issuer URL.
|
|
109
|
+
|
|
110
|
+
The `issuer` passed into createApiKeyRouter MUST be the full URL corresponding to the root of the jwksRouter.
|
|
111
|
+
|
|
112
|
+
For example, if your domain is `https://example.com` and you use the baseIssuer as `https://example.com`, your issuer MUST be `https://example.com`. The JWKS endpoint will be available at `https://example.com/{kid}/.well-known/jwks.json`.
|
|
113
|
+
|
|
114
|
+
The issuer URL does not necessarily have anything to do with the URL that the apiKeyRouter is on. Your apiKeyRoute could be `https://example.com/api-keys` or `https://example.com/my/long/subpath` and that doesn't have any impact on the JWKS endpoint.
|
|
115
|
+
|
|
116
|
+
Besides the issuer, the other critical parameter passed in is the `getUserId` function. That function MUST integrate with your existing authentication system. It MUST raise an exception if the user is not authenticated.
|
|
117
|
+
|
|
118
|
+
You can throw any error that you want from the method. If you throw one of the errors provided by japikeys (such as `UnauthorizedError`), the router will make sure it gets converted to the proper HTTP status code, with a JSON body for clients.
|
|
119
|
+
|
|
120
|
+
Lastly, the parseCreateApiKeyRequest lets you specify what the payload is to the CREATE endpoint (`POST /api-keys`). You at least need to pass back an expiredAt parameter. (You could always hardcode this to e.g. Date.now() + one year) for example. Any other data is up to your application needs. `claims` get encoded in the token themselves, and `databaseMetadata` goes in the database for any of your business logic later.
|
|
121
|
+
|
|
122
|
+
# Setting up D1 Database
|
|
123
|
+
|
|
124
|
+
You'll need to create a D1 database and bind it to your worker. In your `wrangler.toml`:
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
[[d1_databases]]
|
|
128
|
+
binding = "DB"
|
|
129
|
+
database_name = "japikey-db"
|
|
130
|
+
database_id = "your-database-id"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The D1Driver will automatically create the required table when you call `ensureTable()`. The table structure is:
|
|
134
|
+
|
|
135
|
+
```sql
|
|
136
|
+
CREATE TABLE IF NOT EXISTS japikeys (
|
|
137
|
+
kid TEXT PRIMARY KEY,
|
|
138
|
+
user_id TEXT NOT NULL,
|
|
139
|
+
revoked INTEGER NOT NULL,
|
|
140
|
+
jwk TEXT NOT NULL,
|
|
141
|
+
metadata TEXT NOT NULL
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
# Validating an API key
|
|
146
|
+
|
|
147
|
+
Whether you're in the backend, or even on the browser frontend, verifying an API key is a simple process.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { createGetJWKS, authenticate } from '@japikey/authenticate';
|
|
151
|
+
|
|
152
|
+
const issuer = new URL('https://example.com/');
|
|
153
|
+
const getJWKS = createGetJWKS(issuer);
|
|
154
|
+
await authenticate(my_auth_token, { getJWKS, baseIssuer: issuer });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
# API Endpoints
|
|
158
|
+
|
|
159
|
+
The API key router provides the following endpoints:
|
|
160
|
+
|
|
161
|
+
- `POST /api-keys` - Create a new API key
|
|
162
|
+
- `GET /api-keys/my` - List user's API keys
|
|
163
|
+
- `GET /api-keys/:id` - Get specific API key details
|
|
164
|
+
- `DELETE /api-keys/:id` - Revoke an API key
|
|
165
|
+
|
|
166
|
+
The JWKS router provides:
|
|
167
|
+
|
|
168
|
+
- `GET /{kid}/.well-known/jwks.json` - Get public key for validation
|
|
169
|
+
|
|
170
|
+
# Error Handling
|
|
171
|
+
|
|
172
|
+
The router automatically handles common errors and returns appropriate HTTP status codes:
|
|
173
|
+
|
|
174
|
+
- `400 Bad Request` - Invalid request data
|
|
175
|
+
- `401 Unauthorized` - Authentication required
|
|
176
|
+
- `404 Not Found` - Resource not found
|
|
177
|
+
- `500 Internal Server Error` - Server error
|
|
178
|
+
|
|
179
|
+
All error responses include a JSON body with error details.
|
package/eslint.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@japikey/cloudflare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cloudflare worker-based endpoints for japikey",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.5.0"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"cloudflare",
|
|
10
|
+
"japikey"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/susu-dot-dev/japikey_js#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/susu-dot-dev/japikey_js/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/susu-dot-dev/japikey_js.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Anil Kulkarni",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest",
|
|
33
|
+
"test:coverage": "vitest --coverage",
|
|
34
|
+
"clean": "rimraf dist",
|
|
35
|
+
"build": "npm run clean && tsc",
|
|
36
|
+
"lint": "eslint --flag v10_config_lookup_from_file .",
|
|
37
|
+
"lint:fix": "eslint . --fix",
|
|
38
|
+
"format": "prettier --write src/**/*.ts",
|
|
39
|
+
"format:check": "prettier --check src/**/*.ts"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@japikey/japikey": "0.1.0",
|
|
43
|
+
"path-to-regexp": "^8.2.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@cloudflare/workers-types": "^4.20250806.0",
|
|
47
|
+
"@types/node": "^24.1.0",
|
|
48
|
+
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
|
49
|
+
"@typescript-eslint/parser": "^8.38.0",
|
|
50
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
51
|
+
"eslint": "^9.32.0",
|
|
52
|
+
"eslint-config-prettier": "^10.1.8",
|
|
53
|
+
"globals": "^14.0.0",
|
|
54
|
+
"jose": "^6.0.12",
|
|
55
|
+
"prettier": "^3.6.2",
|
|
56
|
+
"rimraf": "^6.0.1",
|
|
57
|
+
"vitest": "^3.2.4"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/d1.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ApiKeyRow, DatabaseDriver } from '@japikey/shared';
|
|
2
|
+
import {
|
|
3
|
+
IncorrectUsageError,
|
|
4
|
+
DatabaseError,
|
|
5
|
+
InvalidInputError,
|
|
6
|
+
TRUNCATE_TABLE_ONLY_USE_FOR_UNIT_TESTS,
|
|
7
|
+
} from '@japikey/shared';
|
|
8
|
+
import type {
|
|
9
|
+
D1Database,
|
|
10
|
+
D1PreparedStatement,
|
|
11
|
+
D1Result,
|
|
12
|
+
} from '@cloudflare/workers-types';
|
|
13
|
+
|
|
14
|
+
type Queries = {
|
|
15
|
+
insert: D1PreparedStatement;
|
|
16
|
+
get: D1PreparedStatement;
|
|
17
|
+
find: D1PreparedStatement;
|
|
18
|
+
revoke: D1PreparedStatement;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function toSqlite(apiKey: ApiKeyRow): Record<keyof ApiKeyRow, unknown> {
|
|
22
|
+
try {
|
|
23
|
+
return {
|
|
24
|
+
kid: apiKey.kid,
|
|
25
|
+
user_id: apiKey.user_id,
|
|
26
|
+
revoked: apiKey.revoked ? 1 : 0,
|
|
27
|
+
jwk: JSON.stringify(apiKey.jwk),
|
|
28
|
+
metadata: JSON.stringify(apiKey.metadata),
|
|
29
|
+
};
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new InvalidInputError('Failed to serialize the metadata', {
|
|
32
|
+
cause: err,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fromSqlite(result: Record<keyof ApiKeyRow, unknown>): ApiKeyRow {
|
|
38
|
+
try {
|
|
39
|
+
return {
|
|
40
|
+
kid: result.kid as string,
|
|
41
|
+
user_id: result.user_id as string,
|
|
42
|
+
revoked: result.revoked === 1,
|
|
43
|
+
jwk: JSON.parse(result.jwk as string),
|
|
44
|
+
metadata: JSON.parse(result.metadata as string),
|
|
45
|
+
};
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new DatabaseError('Failed to deserialize an ApiKeyRow', {
|
|
48
|
+
cause: err,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default class D1Driver implements DatabaseDriver {
|
|
54
|
+
private _queries?: Queries;
|
|
55
|
+
private tableName: string;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private db: D1Database,
|
|
59
|
+
tableName: string = 'japikeys'
|
|
60
|
+
) {
|
|
61
|
+
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) {
|
|
62
|
+
throw new IncorrectUsageError('Invalid table name');
|
|
63
|
+
}
|
|
64
|
+
this.tableName = tableName;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private getQuery(name: keyof Queries): D1PreparedStatement {
|
|
68
|
+
if (!this._queries) {
|
|
69
|
+
throw new IncorrectUsageError(
|
|
70
|
+
'Database not initialized. Call ensureTable() first.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return this._queries[name];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private prepareStatements(): void {
|
|
77
|
+
try {
|
|
78
|
+
this._queries = {
|
|
79
|
+
insert: this.db.prepare(
|
|
80
|
+
`INSERT INTO ${this.tableName} (kid, user_id, revoked, jwk, metadata) VALUES (?1, ?2, ?3, ?4, ?5)`
|
|
81
|
+
),
|
|
82
|
+
get: this.db.prepare(`SELECT * FROM ${this.tableName} WHERE kid = ?1`),
|
|
83
|
+
find: this.db.prepare(
|
|
84
|
+
`SELECT * FROM ${this.tableName} WHERE user_id = ?1 LIMIT ?2 OFFSET ?3`
|
|
85
|
+
),
|
|
86
|
+
revoke: this.db.prepare(
|
|
87
|
+
`UPDATE ${this.tableName} SET revoked = 1 WHERE user_id = ?1 AND kid = ?2`
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new DatabaseError('Failed to generate sql prepared statements', {
|
|
92
|
+
cause: err,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async ensureTable(): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
await this.db.exec(`
|
|
100
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
101
|
+
kid TEXT PRIMARY KEY,
|
|
102
|
+
user_id TEXT NOT NULL,
|
|
103
|
+
revoked INTEGER NOT NULL,
|
|
104
|
+
jwk TEXT NOT NULL,
|
|
105
|
+
metadata TEXT NOT NULL
|
|
106
|
+
)
|
|
107
|
+
`);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
throw new DatabaseError('Failed to create the table', {
|
|
110
|
+
cause: err,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
this.prepareStatements();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async insertApiKey(apiKey: ApiKeyRow): Promise<void> {
|
|
117
|
+
const params = toSqlite(apiKey);
|
|
118
|
+
const query = this.getQuery('insert');
|
|
119
|
+
try {
|
|
120
|
+
await query
|
|
121
|
+
.bind(
|
|
122
|
+
params.kid,
|
|
123
|
+
params.user_id,
|
|
124
|
+
params.revoked,
|
|
125
|
+
params.jwk,
|
|
126
|
+
params.metadata
|
|
127
|
+
)
|
|
128
|
+
.run();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new DatabaseError('Failed to insert api key', {
|
|
131
|
+
cause: err,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async getApiKey(kid: string): Promise<ApiKeyRow | null> {
|
|
137
|
+
let result: Record<string, unknown> | undefined | null;
|
|
138
|
+
const query = this.getQuery('get');
|
|
139
|
+
try {
|
|
140
|
+
result = await query.bind(kid).first();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new DatabaseError('Failed to query for the api key', {
|
|
143
|
+
cause: err,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!result) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return fromSqlite(result);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async findApiKeys(
|
|
153
|
+
user_id: string,
|
|
154
|
+
limit?: number,
|
|
155
|
+
offset?: number
|
|
156
|
+
): Promise<ApiKeyRow[]> {
|
|
157
|
+
let response: D1Result<Record<string, unknown>>;
|
|
158
|
+
const query = this.getQuery('find');
|
|
159
|
+
try {
|
|
160
|
+
response = await query.bind(user_id, limit ?? -1, offset ?? 0).all();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new DatabaseError('Failed to query for the api keys', {
|
|
163
|
+
cause: err,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return response.results.map(row => fromSqlite(row));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async revokeApiKey(filter: { user_id: string; kid: string }): Promise<void> {
|
|
170
|
+
const query = this.getQuery('revoke');
|
|
171
|
+
try {
|
|
172
|
+
await query.bind(filter.user_id, filter.kid).run();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new DatabaseError('Failed to revoke the api key', {
|
|
175
|
+
cause: err,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async close(): Promise<void> {}
|
|
181
|
+
|
|
182
|
+
async [TRUNCATE_TABLE_ONLY_USE_FOR_UNIT_TESTS](): Promise<void> {
|
|
183
|
+
await this.db.exec(`DELETE FROM ${this.tableName}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CreateApiKeyRouterOptions, CreateApiKeyData } from './router.ts';
|
|
2
|
+
import { createApiKeyRouter, createJWKSRouter, isJWKSPath } from './router.ts';
|
|
3
|
+
import D1Driver from './d1.ts';
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
createApiKeyRouter,
|
|
7
|
+
createJWKSRouter,
|
|
8
|
+
isJWKSPath,
|
|
9
|
+
D1Driver,
|
|
10
|
+
type CreateApiKeyRouterOptions,
|
|
11
|
+
type CreateApiKeyData,
|
|
12
|
+
};
|