@codefox-inc/oauth-provider 0.2.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/LICENSE +201 -0
- package/README.md +572 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/auth-config.d.ts +85 -0
- package/dist/client/auth-config.d.ts.map +1 -0
- package/dist/client/auth-config.js +81 -0
- package/dist/client/auth-config.js.map +1 -0
- package/dist/client/auth-helper.d.ts +81 -0
- package/dist/client/auth-helper.d.ts.map +1 -0
- package/dist/client/auth-helper.js +97 -0
- package/dist/client/auth-helper.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +230 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/routes.d.ts +94 -0
- package/dist/client/routes.d.ts.map +1 -0
- package/dist/client/routes.js +113 -0
- package/dist/client/routes.js.map +1 -0
- package/dist/component/_generated/api.d.ts +44 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +123 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/clientManagement.d.ts +39 -0
- package/dist/component/clientManagement.d.ts.map +1 -0
- package/dist/component/clientManagement.js +169 -0
- package/dist/component/clientManagement.js.map +1 -0
- package/dist/component/constants.d.ts +31 -0
- package/dist/component/constants.d.ts.map +1 -0
- package/dist/component/constants.js +36 -0
- package/dist/component/constants.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/handlers.d.ts +143 -0
- package/dist/component/handlers.d.ts.map +1 -0
- package/dist/component/handlers.js +624 -0
- package/dist/component/handlers.js.map +1 -0
- package/dist/component/mutations.d.ts +111 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +459 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +127 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +145 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +116 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +77 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/token_security.d.ts +53 -0
- package/dist/component/token_security.d.ts.map +1 -0
- package/dist/component/token_security.js +91 -0
- package/dist/component/token_security.js.map +1 -0
- package/dist/lib/convex-types.d.ts +21 -0
- package/dist/lib/convex-types.d.ts.map +1 -0
- package/dist/lib/convex-types.js +2 -0
- package/dist/lib/convex-types.js.map +1 -0
- package/dist/lib/oauth.d.ts +123 -0
- package/dist/lib/oauth.d.ts.map +1 -0
- package/dist/lib/oauth.js +295 -0
- package/dist/lib/oauth.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +121 -0
- package/src/client/__tests__/auth-config.test.ts +244 -0
- package/src/client/__tests__/auth-helper.test.ts +273 -0
- package/src/client/__tests__/oauth-provider.test.ts +418 -0
- package/src/client/__tests__/routes.test.ts +428 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/auth-config.ts +157 -0
- package/src/client/auth-helper.ts +201 -0
- package/src/client/index.ts +326 -0
- package/src/client/routes.ts +251 -0
- package/src/component/__tests__/oauth.test.ts +3310 -0
- package/src/component/__tests__/rfc-compliance.test.ts +788 -0
- package/src/component/__tests__/token-security.test.ts +133 -0
- package/src/component/_generated/api.ts +60 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/clientManagement.ts +189 -0
- package/src/component/constants.ts +40 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/handlers.ts +964 -0
- package/src/component/mutations.ts +531 -0
- package/src/component/queries.ts +165 -0
- package/src/component/schema.ts +92 -0
- package/src/component/token_security.ts +102 -0
- package/src/lib/__tests__/oauth-helpers.test.ts +143 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +405 -0
- package/src/lib/convex-types.ts +37 -0
- package/src/lib/oauth.ts +412 -0
- package/src/react/index.ts +7 -0
- package/src/test.ts +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# @codefox-inc/oauth-provider
|
|
2
|
+
|
|
3
|
+
OAuth 2.1 / OpenID Connect Provider implemented as a Convex component.
|
|
4
|
+
|
|
5
|
+
> **⚠️ Beta Software**
|
|
6
|
+
> Built for [Convex Auth](https://labs.convex.dev/auth) which is currently in **Beta**.
|
|
7
|
+
> Expect breaking changes. Production use at your own risk.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm add @codefox-inc/oauth-provider
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **OAuth 2.1 compliant** authorization and token endpoints
|
|
18
|
+
- **OpenID Connect Discovery** for automatic client configuration
|
|
19
|
+
- **PKCE required** for all authorization code flows (S256 only)
|
|
20
|
+
- **Secure token storage** using SHA-256 hashing for tokens and authorization codes
|
|
21
|
+
- **JWT access tokens** with RS256 signing
|
|
22
|
+
- **Refresh token rotation** for enhanced security
|
|
23
|
+
- **Dynamic client registration** (opt-in)
|
|
24
|
+
- **Authorization management** for user consent tracking
|
|
25
|
+
- **JWKS endpoint** for token verification
|
|
26
|
+
|
|
27
|
+
## OAuth 2.1 Compliance
|
|
28
|
+
|
|
29
|
+
This implementation follows [OAuth 2.1 (draft-ietf-oauth-v2-1-14)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-14) specification:
|
|
30
|
+
|
|
31
|
+
### Supported Grant Types
|
|
32
|
+
- ✅ **Authorization Code with PKCE** (public and confidential clients)
|
|
33
|
+
- ✅ **Refresh Token** (with token rotation)
|
|
34
|
+
|
|
35
|
+
### Unsupported Features (OAuth 2.0 Legacy)
|
|
36
|
+
- ❌ **Implicit Grant** (removed in OAuth 2.1 for security reasons)
|
|
37
|
+
- ❌ **Resource Owner Password Credentials Grant** (removed in OAuth 2.1)
|
|
38
|
+
- ❌ **PKCE Plain Method** (only S256 is supported per OAuth 2.1 best practices)
|
|
39
|
+
|
|
40
|
+
### Key Security Requirements
|
|
41
|
+
- **PKCE Enforcement**: All authorization code flows require PKCE with S256 method
|
|
42
|
+
- **Redirect URI Validation**: Exact string matching (with localhost variable port exception per RFC 8252)
|
|
43
|
+
- **Authorization Code**: Single-use, expires in 10 minutes
|
|
44
|
+
- **Token Hashing**: All tokens stored as SHA-256 hashes
|
|
45
|
+
- **Refresh Token Rotation**: New refresh token issued on each use, old token invalidated
|
|
46
|
+
|
|
47
|
+
## Security Features
|
|
48
|
+
|
|
49
|
+
### Built-in Security Controls
|
|
50
|
+
|
|
51
|
+
- **PKCE Enforcement**: All authorization code flows require PKCE (code_challenge/code_verifier)
|
|
52
|
+
- **Redirect URI Validation**: Strict checking against registered URIs
|
|
53
|
+
- **Scope Validation**: Only registered scopes are allowed per client
|
|
54
|
+
- **Token Hashing**: Access and refresh tokens are stored as SHA-256 hashes
|
|
55
|
+
- **Client Secret Hashing**: Confidential client secrets use bcrypt
|
|
56
|
+
- **Internal Mutations**: Critical operations like `issueAuthorizationCode` are not directly accessible
|
|
57
|
+
- **DCR Disabled by Default**: Dynamic Client Registration must be explicitly enabled
|
|
58
|
+
|
|
59
|
+
### Authorization Flow Security
|
|
60
|
+
|
|
61
|
+
The `/oauth/authorize` endpoint performs comprehensive validation:
|
|
62
|
+
1. Client ID verification
|
|
63
|
+
2. Redirect URI matching against registered URIs
|
|
64
|
+
3. Scope validation against client's allowed scopes
|
|
65
|
+
4. PKCE requirement (code_challenge with S256 method)
|
|
66
|
+
5. User authentication via `getUserId` hook
|
|
67
|
+
|
|
68
|
+
## Scopes and Token Types
|
|
69
|
+
|
|
70
|
+
### Supported Scopes
|
|
71
|
+
|
|
72
|
+
- **`openid`**: Required for OpenID Connect authentication and ID tokens
|
|
73
|
+
- **`profile`**: Grants access to user profile information (name, picture)
|
|
74
|
+
- **`email`**: Grants access to user email address
|
|
75
|
+
- **`offline_access`**: Enables refresh token issuance for long-lived access
|
|
76
|
+
|
|
77
|
+
### Refresh Token Requirements
|
|
78
|
+
|
|
79
|
+
Refresh tokens are **only issued** when the `offline_access` scope is requested and granted during the initial authorization:
|
|
80
|
+
|
|
81
|
+
- ✅ **With `offline_access`**: Client receives both access token and refresh token
|
|
82
|
+
- ❌ **Without `offline_access`**: Client receives only access token (no refresh token)
|
|
83
|
+
|
|
84
|
+
**Refresh Token Grant Flow:**
|
|
85
|
+
- Use the `refresh_token` grant type to obtain new access tokens
|
|
86
|
+
- The original authorization must have included the `offline_access` scope
|
|
87
|
+
- Refresh tokens are automatically rotated on each use (old token is invalidated)
|
|
88
|
+
- The new refresh token maintains the same scope as the original
|
|
89
|
+
|
|
90
|
+
This follows OAuth 2.1 and OpenID Connect specifications, ensuring that long-lived refresh tokens are only issued with explicit user consent.
|
|
91
|
+
|
|
92
|
+
## OAuth Token Detection Helper
|
|
93
|
+
|
|
94
|
+
Provides helper functions to distinguish between OAuth tokens and session tokens:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { isOAuthToken, getOAuthClientId } from "@codefox-inc/oauth-provider";
|
|
98
|
+
|
|
99
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
100
|
+
|
|
101
|
+
if (isOAuthToken(identity)) {
|
|
102
|
+
// Handle OAuth token (MCP client, third-party apps, etc.)
|
|
103
|
+
const clientId = getOAuthClientId(identity);
|
|
104
|
+
console.log("OAuth client:", clientId);
|
|
105
|
+
} else {
|
|
106
|
+
// Handle Convex Auth session (first-party user)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Setup
|
|
111
|
+
|
|
112
|
+
### 1. Set Environment Variables
|
|
113
|
+
|
|
114
|
+
#### With Convex Auth (Recommended)
|
|
115
|
+
|
|
116
|
+
If you're using [Convex Auth](https://labs.convex.dev/auth), run the setup command:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx @convex-dev/auth
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This automatically sets `JWT_PRIVATE_KEY`, `JWKS`, and `SITE_URL`. The OAuth Provider will use these by default.
|
|
123
|
+
|
|
124
|
+
#### Without Convex Auth
|
|
125
|
+
|
|
126
|
+
Generate RSA keys manually:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Generate private key
|
|
130
|
+
openssl genrsa -out private.pem 2048
|
|
131
|
+
|
|
132
|
+
# Generate JWKS (use https://mkjwk.org or this script)
|
|
133
|
+
node -e "
|
|
134
|
+
const jose = require('jose');
|
|
135
|
+
const fs = require('fs');
|
|
136
|
+
const privateKey = fs.readFileSync('private.pem', 'utf8');
|
|
137
|
+
(async () => {
|
|
138
|
+
const key = await jose.importPKCS8(privateKey, 'RS256');
|
|
139
|
+
const jwk = await jose.exportJWK(key);
|
|
140
|
+
console.log(JSON.stringify({ keys: [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'default-key' }] }));
|
|
141
|
+
})();
|
|
142
|
+
"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Set environment variables:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx convex env set OAUTH_PRIVATE_KEY "-----BEGIN RSA PRIVATE KEY-----\n..."
|
|
149
|
+
npx convex env set OAUTH_JWKS '{"keys":[...]}'
|
|
150
|
+
npx convex env set SITE_URL "https://your-app.example.com"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 2. Register Component
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// convex/convex.config.ts
|
|
157
|
+
import { defineApp } from "convex/server";
|
|
158
|
+
import oauthProvider from "@codefox-inc/oauth-provider/convex.config";
|
|
159
|
+
|
|
160
|
+
const app = defineApp();
|
|
161
|
+
app.use(oauthProvider, { name: "oauthProvider" });
|
|
162
|
+
|
|
163
|
+
export default app;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 3. Configure HTTP Routes
|
|
167
|
+
|
|
168
|
+
#### Option A: Using the Helper Function (Recommended)
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// convex/http.ts
|
|
172
|
+
import { httpAction } from "./_generated/server";
|
|
173
|
+
import { httpRouter } from "convex/server";
|
|
174
|
+
import { OAuthProvider, registerOAuthRoutes } from "@codefox-inc/oauth-provider";
|
|
175
|
+
import { components } from "./_generated/api";
|
|
176
|
+
import { api } from "./_generated/api";
|
|
177
|
+
|
|
178
|
+
const http = httpRouter();
|
|
179
|
+
|
|
180
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
181
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
182
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
183
|
+
siteUrl: process.env.SITE_URL!,
|
|
184
|
+
convexSiteUrl: process.env.CONVEX_SITE_URL,
|
|
185
|
+
// OPTIONAL: OAuth endpoint prefix (default: "/oauth")
|
|
186
|
+
// Note: This must match the route prefix used below.
|
|
187
|
+
// prefix: "/oauth",
|
|
188
|
+
allowedScopes: ["openid", "profile", "email", "offline_access"],
|
|
189
|
+
|
|
190
|
+
// REQUIRED: Authenticate user for authorization endpoint
|
|
191
|
+
getUserId: async (ctx, request) => {
|
|
192
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
193
|
+
return identity?.subject ?? null;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// OPTIONAL: Enable dynamic client registration (default: false)
|
|
197
|
+
allowDynamicClientRegistration: false,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Register all OAuth routes automatically
|
|
201
|
+
registerOAuthRoutes(http, httpAction, oauthProvider, {
|
|
202
|
+
siteUrl: process.env.SITE_URL!,
|
|
203
|
+
// OPTIONAL: Override the prefix used for route registration.
|
|
204
|
+
// By default, this uses oauthProvider's config prefix.
|
|
205
|
+
// prefix: "/oauth",
|
|
206
|
+
getUserProfile: async (ctx, userId) => {
|
|
207
|
+
// Return user profile for /oauth/userinfo endpoint
|
|
208
|
+
const user = await ctx.runQuery(api.users.get, { userId });
|
|
209
|
+
return user ? {
|
|
210
|
+
sub: userId,
|
|
211
|
+
name: user.name,
|
|
212
|
+
email: user.email,
|
|
213
|
+
picture: user.pictureUrl
|
|
214
|
+
} : null;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export default http;
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### Option B: Manual Route Registration
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// convex/http.ts
|
|
225
|
+
import { httpAction } from "./_generated/server";
|
|
226
|
+
import { httpRouter } from "convex/server";
|
|
227
|
+
import { OAuthProvider } from "@codefox-inc/oauth-provider";
|
|
228
|
+
import { components } from "./_generated/api";
|
|
229
|
+
|
|
230
|
+
const http = httpRouter();
|
|
231
|
+
|
|
232
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
233
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
234
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
235
|
+
siteUrl: process.env.SITE_URL!,
|
|
236
|
+
convexSiteUrl: process.env.CONVEX_SITE_URL,
|
|
237
|
+
// OPTIONAL: OAuth endpoint prefix (default: "/oauth")
|
|
238
|
+
// Note: This must match the route prefix used below.
|
|
239
|
+
// prefix: "/oauth",
|
|
240
|
+
allowedScopes: ["openid", "profile", "email", "offline_access"],
|
|
241
|
+
|
|
242
|
+
// REQUIRED: Authenticate user for authorization endpoint
|
|
243
|
+
getUserId: async (ctx, request) => {
|
|
244
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
245
|
+
return identity?.subject ?? null;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// OpenID Connect Discovery
|
|
250
|
+
http.route({
|
|
251
|
+
path: "/oauth/.well-known/openid-configuration",
|
|
252
|
+
method: "GET",
|
|
253
|
+
handler: httpAction((ctx, req) =>
|
|
254
|
+
oauthProvider.handlers.openIdConfiguration(ctx, req)
|
|
255
|
+
),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// JWKS endpoint
|
|
259
|
+
http.route({
|
|
260
|
+
path: "/oauth/.well-known/jwks.json",
|
|
261
|
+
method: "GET",
|
|
262
|
+
handler: httpAction((ctx, req) =>
|
|
263
|
+
oauthProvider.handlers.jwks(ctx, req)
|
|
264
|
+
),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Authorization endpoint (validates and issues auth codes)
|
|
268
|
+
http.route({
|
|
269
|
+
path: "/oauth/authorize",
|
|
270
|
+
method: "GET",
|
|
271
|
+
handler: httpAction((ctx, req) =>
|
|
272
|
+
oauthProvider.handlers.authorize(ctx, req)
|
|
273
|
+
),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Token endpoint
|
|
277
|
+
http.route({
|
|
278
|
+
path: "/oauth/token",
|
|
279
|
+
method: "POST",
|
|
280
|
+
handler: httpAction((ctx, req) =>
|
|
281
|
+
oauthProvider.handlers.token(ctx, req)
|
|
282
|
+
),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// UserInfo endpoint
|
|
286
|
+
http.route({
|
|
287
|
+
path: "/oauth/userinfo",
|
|
288
|
+
method: "GET",
|
|
289
|
+
handler: httpAction((ctx, req) =>
|
|
290
|
+
oauthProvider.handlers.userInfo(ctx, req, async (userId) => {
|
|
291
|
+
const user = await ctx.runQuery(api.users.get, { userId });
|
|
292
|
+
return user ? { sub: userId, name: user.name, email: user.email } : null;
|
|
293
|
+
})
|
|
294
|
+
),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Dynamic Client Registration (optional)
|
|
298
|
+
http.route({
|
|
299
|
+
path: "/oauth/register",
|
|
300
|
+
method: "POST",
|
|
301
|
+
handler: httpAction((ctx, req) =>
|
|
302
|
+
oauthProvider.handlers.register(ctx, req)
|
|
303
|
+
),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
export default http;
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## UserInfo Endpoint
|
|
310
|
+
|
|
311
|
+
Requires `openid` scope. Returns claims based on scopes:
|
|
312
|
+
- `openid`: Always returns `sub`
|
|
313
|
+
- `profile`: Adds `name`, `picture`
|
|
314
|
+
- `email`: Adds `email` (and `email_verified` if available)
|
|
315
|
+
|
|
316
|
+
## Client Registration
|
|
317
|
+
|
|
318
|
+
### Register OAuth Client (Admin)
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// convex/oauthAdmin.ts
|
|
322
|
+
import { mutation } from "./_generated/server";
|
|
323
|
+
import { OAuthProvider } from "@codefox-inc/oauth-provider";
|
|
324
|
+
import { components } from "./_generated/api";
|
|
325
|
+
|
|
326
|
+
export const registerOAuthClient = mutation({
|
|
327
|
+
handler: async (ctx, args: {
|
|
328
|
+
name: string;
|
|
329
|
+
redirectUris: string[];
|
|
330
|
+
scopes: string[];
|
|
331
|
+
type: "confidential" | "public";
|
|
332
|
+
}) => {
|
|
333
|
+
// Check admin permissions
|
|
334
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
335
|
+
if (!identity) throw new Error("Unauthorized");
|
|
336
|
+
|
|
337
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
338
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
339
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
340
|
+
siteUrl: process.env.SITE_URL!,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const result = await oauthProvider.registerClient(ctx, {
|
|
344
|
+
name: args.name,
|
|
345
|
+
redirectUris: args.redirectUris,
|
|
346
|
+
scopes: args.scopes,
|
|
347
|
+
type: args.type,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// IMPORTANT: Save clientSecret securely - it's only returned once!
|
|
351
|
+
return result;
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Authorization Flow
|
|
357
|
+
|
|
358
|
+
### Automatic Authorization Handler
|
|
359
|
+
|
|
360
|
+
The `/oauth/authorize` endpoint handles the complete authorization flow automatically:
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
GET /oauth/authorize?
|
|
364
|
+
response_type=code
|
|
365
|
+
&client_id=CLIENT_ID
|
|
366
|
+
&redirect_uri=REDIRECT_URI
|
|
367
|
+
&scope=openid+profile+email
|
|
368
|
+
&state=STATE
|
|
369
|
+
&code_challenge=CHALLENGE
|
|
370
|
+
&code_challenge_method=S256
|
|
371
|
+
&nonce=NONCE
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The handler:
|
|
375
|
+
1. Validates the client ID
|
|
376
|
+
2. Checks redirect_uri against registered URIs
|
|
377
|
+
3. Validates requested scopes
|
|
378
|
+
4. Requires PKCE (code_challenge)
|
|
379
|
+
5. Authenticates the user via `getUserId`
|
|
380
|
+
6. Issues authorization code
|
|
381
|
+
7. Redirects back to the client with the code
|
|
382
|
+
|
|
383
|
+
### Custom Authorization Flow (Advanced)
|
|
384
|
+
|
|
385
|
+
If you need custom consent UI, you can use the SDK methods directly:
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// convex/oauth.ts
|
|
389
|
+
import { mutation } from "./_generated/server";
|
|
390
|
+
import { OAuthProvider } from "@codefox-inc/oauth-provider";
|
|
391
|
+
import { components } from "./_generated/api";
|
|
392
|
+
|
|
393
|
+
export const approveAuthorization = mutation({
|
|
394
|
+
handler: async (ctx, args: {
|
|
395
|
+
clientId: string;
|
|
396
|
+
scopes: string[];
|
|
397
|
+
redirectUri: string;
|
|
398
|
+
codeChallenge: string;
|
|
399
|
+
codeChallengeMethod: string;
|
|
400
|
+
nonce?: string;
|
|
401
|
+
}) => {
|
|
402
|
+
// Verify user is authenticated
|
|
403
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
404
|
+
if (!identity) throw new Error("Not authenticated");
|
|
405
|
+
|
|
406
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
407
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
408
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
409
|
+
siteUrl: process.env.SITE_URL!,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Issue authorization code (automatically creates authorization record)
|
|
413
|
+
const authCode = await oauthProvider.issueAuthorizationCode(ctx, {
|
|
414
|
+
userId: identity.subject,
|
|
415
|
+
clientId: args.clientId,
|
|
416
|
+
scopes: args.scopes,
|
|
417
|
+
redirectUri: args.redirectUri,
|
|
418
|
+
codeChallenge: args.codeChallenge,
|
|
419
|
+
codeChallengeMethod: args.codeChallengeMethod,
|
|
420
|
+
nonce: args.nonce,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return authCode;
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## Authorization Management
|
|
429
|
+
|
|
430
|
+
### List User's Authorized Apps
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
import { query } from "./_generated/server";
|
|
434
|
+
import { OAuthProvider } from "@codefox-inc/oauth-provider";
|
|
435
|
+
import { components } from "./_generated/api";
|
|
436
|
+
|
|
437
|
+
export const listAuthorizedApps = query({
|
|
438
|
+
handler: async (ctx) => {
|
|
439
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
440
|
+
if (!identity) return [];
|
|
441
|
+
|
|
442
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
443
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
444
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
445
|
+
siteUrl: process.env.SITE_URL!,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return await oauthProvider.listUserAuthorizations(ctx, identity.subject);
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Revoke Authorization
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
import { mutation } from "./_generated/server";
|
|
457
|
+
import { OAuthProvider } from "@codefox-inc/oauth-provider";
|
|
458
|
+
import { components } from "./_generated/api";
|
|
459
|
+
|
|
460
|
+
export const revokeApp = mutation({
|
|
461
|
+
handler: async (ctx, args: { clientId: string }) => {
|
|
462
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
463
|
+
if (!identity) throw new Error("Not authenticated");
|
|
464
|
+
|
|
465
|
+
const oauthProvider = new OAuthProvider(components.oauthProvider, {
|
|
466
|
+
privateKey: process.env.OAUTH_PRIVATE_KEY!,
|
|
467
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
468
|
+
siteUrl: process.env.SITE_URL!,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Deletes authorization and all associated tokens
|
|
472
|
+
await oauthProvider.revokeAuthorization(ctx, identity.subject, args.clientId);
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Configuration Options
|
|
478
|
+
|
|
479
|
+
### OAuthConfig
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
interface OAuthConfig {
|
|
483
|
+
// REQUIRED: RSA private key in PEM format
|
|
484
|
+
privateKey: string;
|
|
485
|
+
|
|
486
|
+
// REQUIRED: JWKS for token verification (public keys only)
|
|
487
|
+
jwks: string;
|
|
488
|
+
|
|
489
|
+
// REQUIRED: Your application URL
|
|
490
|
+
siteUrl: string;
|
|
491
|
+
|
|
492
|
+
// OPTIONAL: Convex deployment URL (if different from siteUrl)
|
|
493
|
+
convexSiteUrl?: string;
|
|
494
|
+
|
|
495
|
+
// OPTIONAL: OAuth endpoint prefix (default: "/oauth")
|
|
496
|
+
// Normalized to a leading slash, trailing slash removed; "/" means root.
|
|
497
|
+
// Must match the route prefix you register in http.ts.
|
|
498
|
+
prefix?: string;
|
|
499
|
+
|
|
500
|
+
// OPTIONAL: Comma-separated list of allowed CORS origins
|
|
501
|
+
allowedOrigins?: string;
|
|
502
|
+
|
|
503
|
+
// OPTIONAL: Allowed scopes for dynamic client registration
|
|
504
|
+
allowedScopes?: string[];
|
|
505
|
+
|
|
506
|
+
// REQUIRED: Function to get authenticated user ID
|
|
507
|
+
// Must return a Convex users table Id (string)
|
|
508
|
+
// Returns null if user is not authenticated
|
|
509
|
+
getUserId?: (ctx: ActionCtx, request: Request) => Promise<string | null> | string | null;
|
|
510
|
+
|
|
511
|
+
// OPTIONAL: Enable dynamic client registration (default: false)
|
|
512
|
+
allowDynamicClientRegistration?: boolean;
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## Token Verification
|
|
517
|
+
|
|
518
|
+
### In Convex Functions
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { query } from "./_generated/server";
|
|
522
|
+
|
|
523
|
+
export const protectedQuery = query({
|
|
524
|
+
handler: async (ctx) => {
|
|
525
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
526
|
+
if (!identity) throw new Error("Not authenticated");
|
|
527
|
+
|
|
528
|
+
// Token is already verified by Convex Auth
|
|
529
|
+
// Use identity.subject for user ID
|
|
530
|
+
return { userId: identity.subject };
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### External Token Verification
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import { verifyAccessToken } from "@codefox-inc/oauth-provider";
|
|
539
|
+
|
|
540
|
+
const payload = await verifyAccessToken(
|
|
541
|
+
token,
|
|
542
|
+
{
|
|
543
|
+
jwks: process.env.OAUTH_JWKS!,
|
|
544
|
+
siteUrl: process.env.SITE_URL!,
|
|
545
|
+
},
|
|
546
|
+
issuerUrl
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
console.log("User ID:", payload.sub);
|
|
550
|
+
console.log("Scopes:", payload.scp);
|
|
551
|
+
console.log("Client ID:", payload.cid);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
## Environment Variables Reference
|
|
555
|
+
|
|
556
|
+
| Variable | Required | Description |
|
|
557
|
+
|----------|----------|-------------|
|
|
558
|
+
| `OAUTH_PRIVATE_KEY` | Yes | RSA private key (PEM format) |
|
|
559
|
+
| `OAUTH_JWKS` | Yes | JSON Web Key Set for token verification |
|
|
560
|
+
| `SITE_URL` | Yes | Your application's public URL |
|
|
561
|
+
| `CONVEX_SITE_URL` | No | Convex deployment URL (used as issuer if set) |
|
|
562
|
+
| `ALLOWED_ORIGINS` | No | Comma-separated CORS origins |
|
|
563
|
+
|
|
564
|
+
## Testing
|
|
565
|
+
|
|
566
|
+
```bash
|
|
567
|
+
npm test
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
## License
|
|
571
|
+
|
|
572
|
+
Apache-2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=_ignore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_ignore.d.ts","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_ignore.js","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":";AAAA,kEAAkE"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Config Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates auth.config.ts configuration for Convex Auth
|
|
5
|
+
* to trust JWTs from the OAuth Provider.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Auth provider configuration for Convex
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthProvider {
|
|
11
|
+
domain: string;
|
|
12
|
+
applicationID: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Auth config structure (matches Convex Auth config)
|
|
16
|
+
*/
|
|
17
|
+
export interface AuthConfig {
|
|
18
|
+
providers: AuthProvider[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Options for generating auth config
|
|
22
|
+
*/
|
|
23
|
+
export interface GenerateAuthConfigOptions {
|
|
24
|
+
/**
|
|
25
|
+
* CONVEX_SITE_URL - the deployed Convex site URL
|
|
26
|
+
* @example "https://your-app.convex.site"
|
|
27
|
+
*/
|
|
28
|
+
convexSiteUrl?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Local development port for OAuth provider
|
|
31
|
+
* @default 5173
|
|
32
|
+
*/
|
|
33
|
+
localPort?: number;
|
|
34
|
+
/**
|
|
35
|
+
* OAuth endpoint prefix
|
|
36
|
+
* @default "/oauth"
|
|
37
|
+
*/
|
|
38
|
+
prefix?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Audience value for JWT validation
|
|
41
|
+
* @default "convex"
|
|
42
|
+
*/
|
|
43
|
+
applicationID?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Additional provider domains to trust
|
|
46
|
+
*/
|
|
47
|
+
additionalProviders?: AuthProvider[];
|
|
48
|
+
/**
|
|
49
|
+
* Include the CONVEX_SITE_URL as a provider (for Convex Auth)
|
|
50
|
+
* @default true
|
|
51
|
+
*/
|
|
52
|
+
includeConvexSiteUrl?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Generate auth.config.ts configuration for OAuth Provider
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* // convex/auth.config.ts
|
|
60
|
+
* import { generateAuthConfig } from "@codefox-inc/oauth-provider";
|
|
61
|
+
*
|
|
62
|
+
* export default generateAuthConfig({
|
|
63
|
+
* convexSiteUrl: process.env.CONVEX_SITE_URL,
|
|
64
|
+
* localPort: 5173,
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @example Output
|
|
69
|
+
* ```javascript
|
|
70
|
+
* {
|
|
71
|
+
* providers: [
|
|
72
|
+
* { domain: "https://your-app.convex.site", applicationID: "convex" },
|
|
73
|
+
* { domain: "http://localhost:5173/oauth", applicationID: "convex" },
|
|
74
|
+
* { domain: "https://your-app.convex.site/oauth", applicationID: "convex" },
|
|
75
|
+
* ]
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export declare function generateAuthConfig(options?: GenerateAuthConfigOptions): AuthConfig;
|
|
80
|
+
/**
|
|
81
|
+
* Create auth config with validation
|
|
82
|
+
* Throws if required environment variables are missing
|
|
83
|
+
*/
|
|
84
|
+
export declare function createAuthConfig(options?: GenerateAuthConfigOptions): AuthConfig;
|
|
85
|
+
//# sourceMappingURL=auth-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-config.d.ts","sourceRoot":"","sources":["../../src/client/auth-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;GAEG;AACH,MAAM,WAAW,YAAY;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACvB,SAAS,EAAE,YAAY,EAAE,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACtC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,mBAAmB,CAAC,EAAE,YAAY,EAAE,CAAC;IAErC;;;OAGG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,yBAA8B,GAAG,UAAU,CAuCtF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,yBAA8B,GAAG,UAAU,CAsBpF"}
|