@cloudflare/workers-oauth-provider 0.2.2 → 0.2.4
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 +18 -0
- package/dist/oauth-provider.d.ts +134 -13
- package/dist/oauth-provider.js +343 -49
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -81,6 +81,12 @@ export default new OAuthProvider({
|
|
|
81
81
|
// Defaults to false.
|
|
82
82
|
allowImplicitFlow: false,
|
|
83
83
|
|
|
84
|
+
// Optional: Controls whether the plain PKCE code_challenge_method is allowed.
|
|
85
|
+
// OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
|
|
86
|
+
// When false, only S256 is accepted and advertised in the metadata endpoint.
|
|
87
|
+
// Defaults to true for backward compatibility.
|
|
88
|
+
allowPlainPKCE: true,
|
|
89
|
+
|
|
84
90
|
// Optional: Controls whether public clients (clients without a secret, like SPAs)
|
|
85
91
|
// can register via the dynamic client registration endpoint.
|
|
86
92
|
// When true, only confidential clients can register.
|
|
@@ -304,6 +310,18 @@ new OAuthProvider({
|
|
|
304
310
|
|
|
305
311
|
By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
|
|
306
312
|
|
|
313
|
+
## Standards Compliance
|
|
314
|
+
|
|
315
|
+
This library implements the following OAuth and MCP specifications:
|
|
316
|
+
|
|
317
|
+
- [OAuth 2.1 (draft-ietf-oauth-v2-1-13)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) — Core authorization framework with PKCE
|
|
318
|
+
- [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` discovery endpoint
|
|
319
|
+
- [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
|
|
320
|
+
- [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
|
|
321
|
+
- [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URLs as client IDs
|
|
322
|
+
|
|
323
|
+
These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
324
|
+
|
|
307
325
|
## Implementation Notes
|
|
308
326
|
|
|
309
327
|
### End-to-end encryption
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -2,11 +2,21 @@ import { WorkerEntrypoint } from "cloudflare:workers";
|
|
|
2
2
|
|
|
3
3
|
//#region src/oauth-provider.d.ts
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Enum representing OAuth grant types
|
|
7
|
+
*/
|
|
8
|
+
declare enum GrantType {
|
|
9
|
+
AUTHORIZATION_CODE = "authorization_code",
|
|
10
|
+
REFRESH_TOKEN = "refresh_token",
|
|
11
|
+
TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
12
|
+
}
|
|
5
13
|
/**
|
|
6
14
|
* Aliases for either type of Handler that makes .fetch required
|
|
7
15
|
*/
|
|
8
|
-
type ExportedHandlerWithFetch = ExportedHandler & Pick<Required<ExportedHandler
|
|
9
|
-
type WorkerEntrypointWithFetch = WorkerEntrypoint &
|
|
16
|
+
type ExportedHandlerWithFetch<Env = Cloudflare.Env> = ExportedHandler<Env> & Pick<Required<ExportedHandler<Env>>, 'fetch'>;
|
|
17
|
+
type WorkerEntrypointWithFetch<Env = Cloudflare.Env> = WorkerEntrypoint<Env> & {
|
|
18
|
+
fetch: NonNullable<WorkerEntrypoint['fetch']>;
|
|
19
|
+
};
|
|
10
20
|
/**
|
|
11
21
|
* Configuration options for the OAuth Provider
|
|
12
22
|
*/
|
|
@@ -42,6 +52,11 @@ interface TokenExchangeCallbackResult {
|
|
|
42
52
|
* refresh token exchange, it will be ignored.
|
|
43
53
|
*/
|
|
44
54
|
refreshTokenTTL?: number;
|
|
55
|
+
/**
|
|
56
|
+
* List of scopes authorized for the new access token
|
|
57
|
+
* (If undefined, the granted scopes will be used)
|
|
58
|
+
*/
|
|
59
|
+
accessTokenScope?: string[];
|
|
45
60
|
}
|
|
46
61
|
/**
|
|
47
62
|
* Options for token exchange callback functions
|
|
@@ -49,10 +64,8 @@ interface TokenExchangeCallbackResult {
|
|
|
49
64
|
interface TokenExchangeCallbackOptions {
|
|
50
65
|
/**
|
|
51
66
|
* The type of grant being processed.
|
|
52
|
-
* 'authorization_code' for initial code exchange,
|
|
53
|
-
* 'refresh_token' for refresh token exchange.
|
|
54
67
|
*/
|
|
55
|
-
grantType:
|
|
68
|
+
grantType: GrantType;
|
|
56
69
|
/**
|
|
57
70
|
* Client that received this grant
|
|
58
71
|
*/
|
|
@@ -65,6 +78,11 @@ interface TokenExchangeCallbackOptions {
|
|
|
65
78
|
* List of scopes that were granted
|
|
66
79
|
*/
|
|
67
80
|
scope: string[];
|
|
81
|
+
/**
|
|
82
|
+
* List of scopes that were requested for this token by the client
|
|
83
|
+
* (Will be the same as granted scopes unless client specifically requested a downscoping)
|
|
84
|
+
*/
|
|
85
|
+
requestedScope: string[];
|
|
68
86
|
/**
|
|
69
87
|
* Application-specific properties currently associated with this grant
|
|
70
88
|
*/
|
|
@@ -103,7 +121,7 @@ interface ResolveExternalTokenResult {
|
|
|
103
121
|
*/
|
|
104
122
|
audience?: string | string[];
|
|
105
123
|
}
|
|
106
|
-
interface OAuthProviderOptions {
|
|
124
|
+
interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
107
125
|
/**
|
|
108
126
|
* URL(s) for API routes. Requests with URLs starting with any of these prefixes
|
|
109
127
|
* will be treated as API requests and require a valid access token.
|
|
@@ -121,7 +139,7 @@ interface OAuthProviderOptions {
|
|
|
121
139
|
* Used with `apiRoute` for the single-handler configuration. This is incompatible with
|
|
122
140
|
* the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
|
|
123
141
|
*/
|
|
124
|
-
apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env:
|
|
142
|
+
apiHandler?: ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
|
|
125
143
|
/**
|
|
126
144
|
* Map of API routes to their corresponding handlers for the multi-handler configuration.
|
|
127
145
|
* The keys are the API routes (strings only, not arrays), and the values are the handlers.
|
|
@@ -132,12 +150,12 @@ interface OAuthProviderOptions {
|
|
|
132
150
|
* `apiRoute` + `apiHandler` (single-handler configuration) OR `apiHandlers` (multi-handler
|
|
133
151
|
* configuration), not both.
|
|
134
152
|
*/
|
|
135
|
-
apiHandlers?: Record<string, ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env:
|
|
153
|
+
apiHandlers?: Record<string, ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>)>;
|
|
136
154
|
/**
|
|
137
155
|
* Handler for all non-API requests or API requests without a valid token.
|
|
138
156
|
* Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint.
|
|
139
157
|
*/
|
|
140
|
-
defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env:
|
|
158
|
+
defaultHandler: ExportedHandler<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
|
|
141
159
|
/**
|
|
142
160
|
* URL of the OAuth authorization endpoint where users can grant permissions.
|
|
143
161
|
* This URL is used in OAuth metadata and is not handled by the provider itself.
|
|
@@ -175,6 +193,20 @@ interface OAuthProviderOptions {
|
|
|
175
193
|
* Defaults to false.
|
|
176
194
|
*/
|
|
177
195
|
allowImplicitFlow?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Controls whether the plain PKCE method is allowed.
|
|
198
|
+
* OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
|
|
199
|
+
* When set to false, only the S256 code_challenge_method will be accepted.
|
|
200
|
+
* Defaults to true for backward compatibility.
|
|
201
|
+
*/
|
|
202
|
+
allowPlainPKCE?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
205
|
+
* When false, the token exchange grant type will not be advertised in metadata
|
|
206
|
+
* and token exchange requests will be rejected.
|
|
207
|
+
* Defaults to false.
|
|
208
|
+
*/
|
|
209
|
+
allowTokenExchangeGrant?: boolean;
|
|
178
210
|
/**
|
|
179
211
|
* Controls whether public clients (clients without a secret, like SPAs) can register via the
|
|
180
212
|
* dynamic client registration endpoint. When true, only confidential clients can register.
|
|
@@ -214,6 +246,40 @@ interface OAuthProviderOptions {
|
|
|
214
246
|
status: number;
|
|
215
247
|
headers: Record<string, string>;
|
|
216
248
|
}) => Response | void;
|
|
249
|
+
/**
|
|
250
|
+
* Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
|
251
|
+
* Controls the response served at /.well-known/oauth-protected-resource.
|
|
252
|
+
*
|
|
253
|
+
* If not provided, the endpoint will be automatically generated using the request origin
|
|
254
|
+
* as the resource identifier, and the token endpoint's origin as the authorization server.
|
|
255
|
+
*/
|
|
256
|
+
resourceMetadata?: {
|
|
257
|
+
/**
|
|
258
|
+
* The protected resource identifier URL (RFC 9728 `resource` field).
|
|
259
|
+
* If not set, defaults to the request URL's origin.
|
|
260
|
+
*/
|
|
261
|
+
resource?: string;
|
|
262
|
+
/**
|
|
263
|
+
* List of authorization server issuer URLs that can issue tokens for this resource.
|
|
264
|
+
* If not set, defaults to the token endpoint's origin (consistent with the issuer
|
|
265
|
+
* in authorization server metadata).
|
|
266
|
+
*/
|
|
267
|
+
authorization_servers?: string[];
|
|
268
|
+
/**
|
|
269
|
+
* Scopes supported by this protected resource.
|
|
270
|
+
* If not set, falls back to the top-level scopesSupported option.
|
|
271
|
+
*/
|
|
272
|
+
scopes_supported?: string[];
|
|
273
|
+
/**
|
|
274
|
+
* Methods by which bearer tokens can be presented to this resource.
|
|
275
|
+
* Defaults to ["header"].
|
|
276
|
+
*/
|
|
277
|
+
bearer_methods_supported?: string[];
|
|
278
|
+
/**
|
|
279
|
+
* Human-readable name for this resource.
|
|
280
|
+
*/
|
|
281
|
+
resource_name?: string;
|
|
282
|
+
};
|
|
217
283
|
}
|
|
218
284
|
/**
|
|
219
285
|
* Helper methods for OAuth operations provided to handler functions
|
|
@@ -285,6 +351,34 @@ interface OAuthHelpers {
|
|
|
285
351
|
* @returns Promise resolving to token data with decrypted props, or null if token is invalid
|
|
286
352
|
*/
|
|
287
353
|
unwrapToken<T = any>(token: string): Promise<TokenSummary<T> | null>;
|
|
354
|
+
/**
|
|
355
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
356
|
+
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
357
|
+
* @param options - Options for token exchange including subject token and optional modifications
|
|
358
|
+
* @returns Promise resolving to token response with new access token
|
|
359
|
+
*/
|
|
360
|
+
exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Options for token exchange operations (RFC 8693)
|
|
364
|
+
*/
|
|
365
|
+
interface ExchangeTokenOptions {
|
|
366
|
+
/**
|
|
367
|
+
* The subject token to exchange (existing access token)
|
|
368
|
+
*/
|
|
369
|
+
subjectToken: string;
|
|
370
|
+
/**
|
|
371
|
+
* Optional narrowed set of scopes for the new token (must be subset of original grant scopes)
|
|
372
|
+
*/
|
|
373
|
+
scope?: string[];
|
|
374
|
+
/**
|
|
375
|
+
* Optional target audience/resource for the new token (maps to resource parameter per RFC 8707)
|
|
376
|
+
*/
|
|
377
|
+
aud?: string | string[];
|
|
378
|
+
/**
|
|
379
|
+
* Optional TTL override for the new token in seconds (must not exceed subject token's remaining lifetime)
|
|
380
|
+
*/
|
|
381
|
+
expiresIn?: number;
|
|
288
382
|
}
|
|
289
383
|
/**
|
|
290
384
|
* Parsed OAuth authorization request parameters
|
|
@@ -496,6 +590,22 @@ interface Grant {
|
|
|
496
590
|
*/
|
|
497
591
|
resource?: string | string[];
|
|
498
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* OAuth 2.0 Token Response
|
|
595
|
+
* The response returned when exchanging authorization codes or refresh tokens
|
|
596
|
+
*/
|
|
597
|
+
interface TokenResponse {
|
|
598
|
+
access_token: string;
|
|
599
|
+
token_type: 'bearer';
|
|
600
|
+
expires_in: number;
|
|
601
|
+
refresh_token?: string;
|
|
602
|
+
scope: string;
|
|
603
|
+
/**
|
|
604
|
+
* Resource indicator(s) for the issued access token (RFC 8707 Section 2.2)
|
|
605
|
+
* SHOULD be included to indicate the resource server(s) for which the token is valid
|
|
606
|
+
*/
|
|
607
|
+
resource?: string | string[];
|
|
608
|
+
}
|
|
499
609
|
/**
|
|
500
610
|
* Shared fields for Token and TokenSummary
|
|
501
611
|
*/
|
|
@@ -525,6 +635,10 @@ interface TokenBase {
|
|
|
525
635
|
* Can be a single string or array of strings
|
|
526
636
|
*/
|
|
527
637
|
audience?: string | string[];
|
|
638
|
+
/**
|
|
639
|
+
* List of scopes on this token
|
|
640
|
+
*/
|
|
641
|
+
scope: string[];
|
|
528
642
|
}
|
|
529
643
|
/**
|
|
530
644
|
* Token record stored in KV
|
|
@@ -643,13 +757,13 @@ interface GrantSummary {
|
|
|
643
757
|
* Implements authorization code flow with support for refresh tokens
|
|
644
758
|
* and dynamic client registration.
|
|
645
759
|
*/
|
|
646
|
-
declare class OAuthProvider {
|
|
760
|
+
declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
647
761
|
#private;
|
|
648
762
|
/**
|
|
649
763
|
* Creates a new OAuth provider instance
|
|
650
764
|
* @param options - Configuration options for the provider
|
|
651
765
|
*/
|
|
652
|
-
constructor(options: OAuthProviderOptions);
|
|
766
|
+
constructor(options: OAuthProviderOptions<Env>);
|
|
653
767
|
/**
|
|
654
768
|
* Main fetch handler for the Worker
|
|
655
769
|
* Routes requests to the appropriate handler based on the URL
|
|
@@ -658,7 +772,14 @@ declare class OAuthProvider {
|
|
|
658
772
|
* @param ctx - Cloudflare Worker execution context
|
|
659
773
|
* @returns A Promise resolving to an HTTP Response
|
|
660
774
|
*/
|
|
661
|
-
fetch(request: Request, env:
|
|
775
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
|
|
662
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* Gets OAuthHelpers for the given environment
|
|
779
|
+
* @param options - Configuration options for the OAuth provider
|
|
780
|
+
* @param env - Cloudflare Worker environment variables
|
|
781
|
+
* @returns An instance of OAuthHelpers
|
|
782
|
+
*/
|
|
783
|
+
declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
|
|
663
784
|
//#endregion
|
|
664
|
-
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, Grant, GrantSummary, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary };
|
|
785
|
+
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -11,6 +11,15 @@ var HandlerType = /* @__PURE__ */ function(HandlerType$1) {
|
|
|
11
11
|
return HandlerType$1;
|
|
12
12
|
}(HandlerType || {});
|
|
13
13
|
/**
|
|
14
|
+
* Enum representing OAuth grant types
|
|
15
|
+
*/
|
|
16
|
+
let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
17
|
+
GrantType$1["AUTHORIZATION_CODE"] = "authorization_code";
|
|
18
|
+
GrantType$1["REFRESH_TOKEN"] = "refresh_token";
|
|
19
|
+
GrantType$1["TOKEN_EXCHANGE"] = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
20
|
+
return GrantType$1;
|
|
21
|
+
}({});
|
|
22
|
+
/**
|
|
14
23
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
|
15
24
|
* Implements authorization code flow with support for refresh tokens
|
|
16
25
|
* and dynamic client registration.
|
|
@@ -37,6 +46,15 @@ var OAuthProvider = class {
|
|
|
37
46
|
}
|
|
38
47
|
};
|
|
39
48
|
/**
|
|
49
|
+
* Gets OAuthHelpers for the given environment
|
|
50
|
+
* @param options - Configuration options for the OAuth provider
|
|
51
|
+
* @param env - Cloudflare Worker environment variables
|
|
52
|
+
* @returns An instance of OAuthHelpers
|
|
53
|
+
*/
|
|
54
|
+
function getOAuthApi(options, env) {
|
|
55
|
+
return new OAuthProviderImpl(options).createOAuthHelpers(env);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
40
58
|
* Implementation class backing OAuthProvider.
|
|
41
59
|
*
|
|
42
60
|
* We use a PImpl pattern in `OAuthProvider` to make sure we don't inadvertently export any private
|
|
@@ -123,7 +141,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
123
141
|
async fetch(request, env, ctx) {
|
|
124
142
|
const url = new URL(request.url);
|
|
125
143
|
if (request.method === "OPTIONS") {
|
|
126
|
-
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
144
|
+
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/oauth-protected-resource" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
127
145
|
status: 204,
|
|
128
146
|
headers: { "Content-Length": "0" }
|
|
129
147
|
}), request);
|
|
@@ -132,6 +150,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
132
150
|
const response = await this.handleMetadataDiscovery(url);
|
|
133
151
|
return this.addCorsHeaders(response, request);
|
|
134
152
|
}
|
|
153
|
+
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
|
154
|
+
const response = this.handleProtectedResourceMetadata(url);
|
|
155
|
+
return this.addCorsHeaders(response, request);
|
|
156
|
+
}
|
|
135
157
|
if (this.isTokenEndpoint(url)) {
|
|
136
158
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
137
159
|
if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
|
|
@@ -176,6 +198,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
176
198
|
createdAt: tokenData.createdAt,
|
|
177
199
|
expiresAt: tokenData.expiresAt,
|
|
178
200
|
audience: tokenData.audience,
|
|
201
|
+
scope: tokenData.scope || grant.scope,
|
|
179
202
|
grant: {
|
|
180
203
|
clientId: grant.clientId,
|
|
181
204
|
scope: grant.scope,
|
|
@@ -268,8 +291,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
268
291
|
* @returns True if the URL matches the API route
|
|
269
292
|
*/
|
|
270
293
|
matchApiRoute(url, route) {
|
|
271
|
-
if (this.isPath(route))
|
|
272
|
-
|
|
294
|
+
if (this.isPath(route)) {
|
|
295
|
+
if (route === "/") return url.pathname === "/";
|
|
296
|
+
return url.pathname.startsWith(route);
|
|
297
|
+
} else {
|
|
273
298
|
const apiUrl = new URL(route);
|
|
274
299
|
return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
|
|
275
300
|
}
|
|
@@ -331,6 +356,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
331
356
|
if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
|
|
332
357
|
const responseTypesSupported = ["code"];
|
|
333
358
|
if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
|
|
359
|
+
const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
|
|
360
|
+
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
334
361
|
const metadata = {
|
|
335
362
|
issuer: new URL(tokenEndpoint).origin,
|
|
336
363
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -339,19 +366,38 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
339
366
|
scopes_supported: this.options.scopesSupported,
|
|
340
367
|
response_types_supported: responseTypesSupported,
|
|
341
368
|
response_modes_supported: ["query"],
|
|
342
|
-
grant_types_supported:
|
|
369
|
+
grant_types_supported: grantTypesSupported,
|
|
343
370
|
token_endpoint_auth_methods_supported: [
|
|
344
371
|
"client_secret_basic",
|
|
345
372
|
"client_secret_post",
|
|
346
373
|
"none"
|
|
347
374
|
],
|
|
348
375
|
revocation_endpoint: tokenEndpoint,
|
|
349
|
-
code_challenge_methods_supported: ["plain", "S256"],
|
|
376
|
+
code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
|
|
350
377
|
client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
|
|
351
378
|
};
|
|
352
379
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
353
380
|
}
|
|
354
381
|
/**
|
|
382
|
+
* Handles the OAuth Protected Resource Metadata endpoint
|
|
383
|
+
* Implements RFC 9728 for OAuth Protected Resource Metadata
|
|
384
|
+
* @param requestUrl - The URL of the incoming request
|
|
385
|
+
* @returns Response with protected resource metadata
|
|
386
|
+
*/
|
|
387
|
+
handleProtectedResourceMetadata(requestUrl) {
|
|
388
|
+
const rm = this.options.resourceMetadata;
|
|
389
|
+
const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
390
|
+
const authServerOrigin = new URL(tokenEndpointUrl).origin;
|
|
391
|
+
const metadata = {
|
|
392
|
+
resource: rm?.resource ?? requestUrl.origin,
|
|
393
|
+
authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
|
|
394
|
+
scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
|
|
395
|
+
bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
|
|
396
|
+
};
|
|
397
|
+
if (rm?.resource_name) metadata.resource_name = rm.resource_name;
|
|
398
|
+
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
355
401
|
* Handles client authentication and token issuance via the token endpoint
|
|
356
402
|
* Supports authorization_code and refresh_token grant types
|
|
357
403
|
* @param body - The parsed request body
|
|
@@ -361,8 +407,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
361
407
|
*/
|
|
362
408
|
async handleTokenRequest(body, clientInfo, env) {
|
|
363
409
|
const grantType = body.grant_type;
|
|
364
|
-
if (grantType ===
|
|
365
|
-
else if (grantType ===
|
|
410
|
+
if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
411
|
+
else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
412
|
+
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
366
413
|
else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
|
|
367
414
|
}
|
|
368
415
|
/**
|
|
@@ -389,7 +436,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
389
436
|
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
390
437
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
391
438
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
|
|
392
|
-
if (redirectUri && !clientInfo.redirectUris
|
|
439
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
|
|
393
440
|
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
|
|
394
441
|
if (isPkceEnabled) {
|
|
395
442
|
if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
|
|
@@ -402,23 +449,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
402
449
|
} else calculatedChallenge = codeVerifier;
|
|
403
450
|
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
|
|
404
451
|
}
|
|
405
|
-
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
406
|
-
const accessTokenId = await generateTokenId(accessToken);
|
|
407
452
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
408
453
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
409
454
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
410
455
|
let grantEncryptionKey = encryptionKey;
|
|
411
456
|
let accessTokenEncryptionKey = encryptionKey;
|
|
412
457
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
458
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
413
459
|
if (this.options.tokenExchangeCallback) {
|
|
414
460
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
415
461
|
let grantProps = decryptedProps;
|
|
416
462
|
let accessTokenProps = decryptedProps;
|
|
417
463
|
const callbackOptions = {
|
|
418
|
-
grantType:
|
|
464
|
+
grantType: GrantType.AUTHORIZATION_CODE,
|
|
419
465
|
clientId: clientInfo.clientId,
|
|
420
466
|
userId,
|
|
421
467
|
scope: grantData.scope,
|
|
468
|
+
requestedScope: tokenScopes,
|
|
422
469
|
props: decryptedProps
|
|
423
470
|
};
|
|
424
471
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -430,6 +477,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
430
477
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
431
478
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
432
479
|
if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
480
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
433
481
|
}
|
|
434
482
|
const grantResult = await encryptProps(grantProps);
|
|
435
483
|
grantData.encryptedProps = grantResult.encryptedData;
|
|
@@ -444,9 +492,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
444
492
|
}
|
|
445
493
|
}
|
|
446
494
|
const now = Math.floor(Date.now() / 1e3);
|
|
447
|
-
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
448
495
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
449
|
-
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
|
|
450
496
|
delete grantData.authCodeId;
|
|
451
497
|
delete grantData.codeChallenge;
|
|
452
498
|
delete grantData.codeChallengeMethod;
|
|
@@ -471,26 +517,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
471
517
|
}
|
|
472
518
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
473
519
|
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
474
|
-
const accessTokenData = {
|
|
475
|
-
id: accessTokenId,
|
|
476
|
-
grantId,
|
|
477
|
-
userId,
|
|
478
|
-
createdAt: now,
|
|
479
|
-
expiresAt: accessTokenExpiresAt,
|
|
480
|
-
audience,
|
|
481
|
-
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
482
|
-
grant: {
|
|
483
|
-
clientId: grantData.clientId,
|
|
484
|
-
scope: grantData.scope,
|
|
485
|
-
encryptedProps: encryptedAccessTokenProps
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
489
520
|
const tokenResponse = {
|
|
490
|
-
access_token:
|
|
521
|
+
access_token: await this.createAccessToken({
|
|
522
|
+
userId,
|
|
523
|
+
grantId,
|
|
524
|
+
clientId: grantData.clientId,
|
|
525
|
+
scope: tokenScopes,
|
|
526
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
527
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
528
|
+
expiresIn: accessTokenTTL,
|
|
529
|
+
audience,
|
|
530
|
+
env
|
|
531
|
+
}),
|
|
491
532
|
token_type: "bearer",
|
|
492
533
|
expires_in: accessTokenTTL,
|
|
493
|
-
scope:
|
|
534
|
+
scope: tokenScopes.join(" ")
|
|
494
535
|
};
|
|
495
536
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
496
537
|
if (audience) tokenResponse.resource = audience;
|
|
@@ -531,16 +572,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
531
572
|
let grantEncryptionKey = encryptionKey;
|
|
532
573
|
let accessTokenEncryptionKey = encryptionKey;
|
|
533
574
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
575
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
534
576
|
let grantPropsChanged = false;
|
|
535
577
|
if (this.options.tokenExchangeCallback) {
|
|
536
578
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
537
579
|
let grantProps = decryptedProps;
|
|
538
580
|
let accessTokenProps = decryptedProps;
|
|
539
581
|
const callbackOptions = {
|
|
540
|
-
grantType:
|
|
582
|
+
grantType: GrantType.REFRESH_TOKEN,
|
|
541
583
|
clientId: clientInfo.clientId,
|
|
542
584
|
userId,
|
|
543
585
|
scope: grantData.scope,
|
|
586
|
+
requestedScope: tokenScopes,
|
|
544
587
|
props: decryptedProps
|
|
545
588
|
};
|
|
546
589
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -553,6 +596,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
553
596
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
554
597
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
555
598
|
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
|
|
599
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
556
600
|
}
|
|
557
601
|
if (grantPropsChanged) {
|
|
558
602
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -600,6 +644,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
600
644
|
createdAt: now,
|
|
601
645
|
expiresAt: accessTokenExpiresAt,
|
|
602
646
|
audience,
|
|
647
|
+
scope: tokenScopes,
|
|
603
648
|
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
604
649
|
grant: {
|
|
605
650
|
clientId: grantData.clientId,
|
|
@@ -613,12 +658,139 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
613
658
|
token_type: "bearer",
|
|
614
659
|
expires_in: accessTokenTTL,
|
|
615
660
|
refresh_token: newRefreshToken,
|
|
616
|
-
scope:
|
|
661
|
+
scope: tokenScopes.join(" ")
|
|
617
662
|
};
|
|
618
663
|
if (audience) tokenResponse.resource = audience;
|
|
619
664
|
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
620
665
|
}
|
|
621
666
|
/**
|
|
667
|
+
* Core token exchange logic (RFC 8693)
|
|
668
|
+
* Performs the actual token exchange operation
|
|
669
|
+
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
670
|
+
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
671
|
+
* module-private.
|
|
672
|
+
* @param subjectToken - The subject token to exchange
|
|
673
|
+
* @param requestedScopes - Optional narrowed scopes (must be subset of original)
|
|
674
|
+
* @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
|
|
675
|
+
* @param expiresIn - Optional TTL override in seconds
|
|
676
|
+
* @param clientInfo - The client making the exchange request
|
|
677
|
+
* @param env - Cloudflare Worker environment variables
|
|
678
|
+
* @returns Promise resolving to token response
|
|
679
|
+
* @throws OAuthError with OAuth error code and description
|
|
680
|
+
*/
|
|
681
|
+
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
682
|
+
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
683
|
+
if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
|
|
684
|
+
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
685
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
686
|
+
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
687
|
+
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
688
|
+
let newAudience = tokenSummary.audience;
|
|
689
|
+
if (requestedResource) {
|
|
690
|
+
if (grantData.resource) {
|
|
691
|
+
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
692
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
693
|
+
for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
694
|
+
}
|
|
695
|
+
const parsedResource = parseResourceParameter(requestedResource);
|
|
696
|
+
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
697
|
+
newAudience = parsedResource;
|
|
698
|
+
}
|
|
699
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
700
|
+
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
701
|
+
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
702
|
+
if (expiresIn !== void 0) {
|
|
703
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
|
|
704
|
+
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
705
|
+
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
706
|
+
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
707
|
+
if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
|
|
708
|
+
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
709
|
+
let accessTokenEncryptionKey = encryptionKey;
|
|
710
|
+
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
711
|
+
if (this.options.tokenExchangeCallback) {
|
|
712
|
+
const decryptedProps = await decryptProps(encryptionKey, subjectTokenData.grant.encryptedProps);
|
|
713
|
+
const callbackOptions = {
|
|
714
|
+
grantType: GrantType.TOKEN_EXCHANGE,
|
|
715
|
+
clientId: clientInfo.clientId,
|
|
716
|
+
userId: tokenSummary.userId,
|
|
717
|
+
scope: tokenSummary.grant.scope,
|
|
718
|
+
requestedScope: tokenScopes,
|
|
719
|
+
props: decryptedProps
|
|
720
|
+
};
|
|
721
|
+
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
722
|
+
if (callbackResult) {
|
|
723
|
+
let accessTokenProps = decryptedProps;
|
|
724
|
+
if (callbackResult.newProps) {
|
|
725
|
+
if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
|
|
726
|
+
}
|
|
727
|
+
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
728
|
+
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
729
|
+
if (accessTokenProps !== decryptedProps) {
|
|
730
|
+
const tokenResult = await encryptProps(accessTokenProps);
|
|
731
|
+
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
732
|
+
accessTokenEncryptionKey = tokenResult.key;
|
|
733
|
+
}
|
|
734
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const tokenResponse = {
|
|
738
|
+
access_token: await this.createAccessToken({
|
|
739
|
+
userId: tokenSummary.userId,
|
|
740
|
+
grantId: tokenSummary.grantId,
|
|
741
|
+
clientId: tokenSummary.grant.clientId,
|
|
742
|
+
scope: tokenScopes,
|
|
743
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
744
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
745
|
+
expiresIn: accessTokenTTL,
|
|
746
|
+
audience: newAudience,
|
|
747
|
+
env
|
|
748
|
+
}),
|
|
749
|
+
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
750
|
+
token_type: "bearer",
|
|
751
|
+
expires_in: accessTokenTTL,
|
|
752
|
+
scope: tokenScopes.join(" ")
|
|
753
|
+
};
|
|
754
|
+
if (newAudience) tokenResponse.resource = newAudience;
|
|
755
|
+
return tokenResponse;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
759
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
760
|
+
* @param body - The parsed request body
|
|
761
|
+
* @param clientInfo - The authenticated client information
|
|
762
|
+
* @param env - Cloudflare Worker environment variables
|
|
763
|
+
* @returns Response with new token data or error
|
|
764
|
+
*/
|
|
765
|
+
async handleTokenExchangeGrant(body, clientInfo, env) {
|
|
766
|
+
const subjectToken = body.subject_token;
|
|
767
|
+
const subjectTokenType = body.subject_token_type;
|
|
768
|
+
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
769
|
+
const requestedScope = body.scope;
|
|
770
|
+
const requestedResource = body.resource;
|
|
771
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
|
|
772
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
|
|
773
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
|
|
774
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
|
|
775
|
+
let requestedScopes;
|
|
776
|
+
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
777
|
+
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
778
|
+
else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
|
|
779
|
+
let expiresIn;
|
|
780
|
+
if (body.expires_in !== void 0) {
|
|
781
|
+
const requestedTTL = parseInt(body.expires_in, 10);
|
|
782
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
|
|
783
|
+
expiresIn = requestedTTL;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
787
|
+
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
622
794
|
* Handles OAuth 2.0 token revocation requests (RFC 7009)
|
|
623
795
|
* @param body - The parsed request body containing revocation parameters
|
|
624
796
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -730,7 +902,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
730
902
|
tosUri: OAuthProviderImpl.validateStringField(clientMetadata.tos_uri),
|
|
731
903
|
jwksUri: OAuthProviderImpl.validateStringField(clientMetadata.jwks_uri),
|
|
732
904
|
contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
|
|
733
|
-
grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
|
|
905
|
+
grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
|
|
906
|
+
GrantType.AUTHORIZATION_CODE,
|
|
907
|
+
GrantType.REFRESH_TOKEN,
|
|
908
|
+
...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
909
|
+
],
|
|
734
910
|
responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
|
|
735
911
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
736
912
|
tokenEndpointAuthMethod: authMethod
|
|
@@ -770,8 +946,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
770
946
|
* @returns Response from the API handler or error
|
|
771
947
|
*/
|
|
772
948
|
async handleApiRequest(request, env, ctx) {
|
|
949
|
+
const url = new URL(request.url);
|
|
950
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
773
951
|
const authHeader = request.headers.get("Authorization");
|
|
774
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate":
|
|
952
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
|
|
775
953
|
const accessToken = authHeader.substring(7);
|
|
776
954
|
const parts = accessToken.split(":");
|
|
777
955
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
@@ -783,14 +961,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
783
961
|
const id = await generateTokenId(accessToken);
|
|
784
962
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
785
963
|
}
|
|
786
|
-
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate":
|
|
964
|
+
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
787
965
|
if (tokenData) {
|
|
788
966
|
const now = Math.floor(Date.now() / 1e3);
|
|
789
|
-
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate":
|
|
967
|
+
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
790
968
|
if (tokenData.audience) {
|
|
791
969
|
const requestUrl = new URL(request.url);
|
|
792
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
|
|
793
|
-
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate":
|
|
970
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
971
|
+
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
|
|
794
972
|
}
|
|
795
973
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
796
974
|
} else if (this.options.resolveExternalToken) {
|
|
@@ -799,16 +977,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
799
977
|
request,
|
|
800
978
|
env
|
|
801
979
|
});
|
|
802
|
-
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate":
|
|
980
|
+
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
803
981
|
if (ext.audience) {
|
|
804
982
|
const requestUrl = new URL(request.url);
|
|
805
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
|
|
806
|
-
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate":
|
|
983
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
984
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
|
|
807
985
|
}
|
|
808
986
|
ctx.props = ext.props;
|
|
809
987
|
}
|
|
810
988
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
811
|
-
const url = new URL(request.url);
|
|
812
989
|
const apiHandler = this.findApiHandlerForUrl(url);
|
|
813
990
|
if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
|
|
814
991
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
@@ -856,6 +1033,45 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
856
1033
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
857
1034
|
}
|
|
858
1035
|
/**
|
|
1036
|
+
* Creates and stores an access token
|
|
1037
|
+
* @param params - Options for creating the access token
|
|
1038
|
+
* @returns The access token string
|
|
1039
|
+
*/
|
|
1040
|
+
async createAccessToken(params) {
|
|
1041
|
+
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
1042
|
+
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1043
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1044
|
+
const accessTokenId = await generateTokenId(accessToken);
|
|
1045
|
+
const accessTokenData = {
|
|
1046
|
+
id: accessTokenId,
|
|
1047
|
+
grantId,
|
|
1048
|
+
userId,
|
|
1049
|
+
createdAt: now,
|
|
1050
|
+
expiresAt: now + expiresIn,
|
|
1051
|
+
audience,
|
|
1052
|
+
scope,
|
|
1053
|
+
wrappedEncryptionKey: await wrapKeyWithToken(accessToken, encryptionKey),
|
|
1054
|
+
grant: {
|
|
1055
|
+
clientId,
|
|
1056
|
+
scope,
|
|
1057
|
+
encryptedProps
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
|
|
1061
|
+
return accessToken;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Downscopes requested scopes to only include those that are in the grant
|
|
1065
|
+
* Filters out any requested scopes that are not in the granted scopes
|
|
1066
|
+
* @param requestedScope - The scope parameter from the request (string or array)
|
|
1067
|
+
* @param grantedScopes - The scopes that were granted in the authorization
|
|
1068
|
+
* @returns The filtered scopes that are a subset of the granted scopes
|
|
1069
|
+
*/
|
|
1070
|
+
downscope(requestedScope, grantedScopes) {
|
|
1071
|
+
if (!requestedScope) return grantedScopes;
|
|
1072
|
+
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
859
1075
|
* Checks if the global_fetch_strictly_public compatibility flag is enabled.
|
|
860
1076
|
* This flag is required for CIMD to prevent SSRF attacks.
|
|
861
1077
|
* See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
|
|
@@ -993,6 +1209,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
993
1209
|
return JSON.parse(text);
|
|
994
1210
|
}
|
|
995
1211
|
/**
|
|
1212
|
+
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
1213
|
+
*/
|
|
1214
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
|
|
1215
|
+
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
|
|
1216
|
+
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
1217
|
+
return header;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
996
1220
|
* Helper function to create OAuth error responses
|
|
997
1221
|
* @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
|
|
998
1222
|
* @param description - Human-readable error description
|
|
@@ -1022,6 +1246,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1022
1246
|
}
|
|
1023
1247
|
};
|
|
1024
1248
|
/**
|
|
1249
|
+
* Error class for OAuth operations
|
|
1250
|
+
* Carries OAuth error code and description for proper error responses
|
|
1251
|
+
*/
|
|
1252
|
+
var OAuthError = class extends Error {
|
|
1253
|
+
constructor(code, message) {
|
|
1254
|
+
super(message);
|
|
1255
|
+
this.code = code;
|
|
1256
|
+
this.name = "OAuthError";
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
/**
|
|
1025
1260
|
* Default expiration time for access tokens (1 hour in seconds)
|
|
1026
1261
|
*/
|
|
1027
1262
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
@@ -1047,14 +1282,23 @@ function validateResourceUri(uri) {
|
|
|
1047
1282
|
}
|
|
1048
1283
|
}
|
|
1049
1284
|
/**
|
|
1050
|
-
* Checks if a resource server matches an audience claim
|
|
1051
|
-
*
|
|
1285
|
+
* Checks if a resource server matches an audience claim.
|
|
1286
|
+
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
1287
|
+
* and path-prefix matching on path boundaries for RFC 8707 resource indicators.
|
|
1052
1288
|
* @param resourceServerUrl - The resource server URL (from request)
|
|
1053
1289
|
* @param audienceValue - The audience value from token
|
|
1054
1290
|
* @returns true if they match, false otherwise
|
|
1055
1291
|
*/
|
|
1056
1292
|
function audienceMatches(resourceServerUrl, audienceValue) {
|
|
1057
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
const resource = new URL(resourceServerUrl);
|
|
1295
|
+
const audience = new URL(audienceValue);
|
|
1296
|
+
if (resource.origin !== audience.origin) return false;
|
|
1297
|
+
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
1298
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
1299
|
+
} catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1058
1302
|
}
|
|
1059
1303
|
/**
|
|
1060
1304
|
* Parses and validates the resource parameter from a token request (RFC 8707)
|
|
@@ -1128,6 +1372,37 @@ function validateRedirectUriScheme(redirectUri) {
|
|
|
1128
1372
|
for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
|
|
1129
1373
|
}
|
|
1130
1374
|
/**
|
|
1375
|
+
* Checks if a URI is a loopback redirect URI (127.0.0.0/8 or ::1)
|
|
1376
|
+
* Per RFC 8252 Section 7.3, these get special port handling
|
|
1377
|
+
*/
|
|
1378
|
+
function isLoopbackUri(uri) {
|
|
1379
|
+
try {
|
|
1380
|
+
const host = new URL(uri).hostname;
|
|
1381
|
+
if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
|
|
1382
|
+
if (host === "::1" || host === "[::1]") return true;
|
|
1383
|
+
return false;
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Validates a redirect URI against registered URIs with RFC 8252 loopback support.
|
|
1390
|
+
* For loopback URIs (127.x.x.x, ::1), any port is allowed as long as scheme, host, path, and query match.
|
|
1391
|
+
* For non-loopback URIs, exact match is required.
|
|
1392
|
+
*/
|
|
1393
|
+
function isValidRedirectUri(requestUri, registeredUris) {
|
|
1394
|
+
return registeredUris.some((registered) => {
|
|
1395
|
+
if (isLoopbackUri(requestUri) && isLoopbackUri(registered)) try {
|
|
1396
|
+
const reqUrl = new URL(requestUri);
|
|
1397
|
+
const regUrl = new URL(registered);
|
|
1398
|
+
return reqUrl.protocol === regUrl.protocol && reqUrl.hostname === regUrl.hostname && reqUrl.pathname === regUrl.pathname && reqUrl.search === regUrl.search;
|
|
1399
|
+
} catch {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
return requestUri === registered;
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1131
1406
|
* Encodes a string as base64url (URL-safe base64)
|
|
1132
1407
|
* @param str - The string to encode
|
|
1133
1408
|
* @returns The base64url encoded string
|
|
@@ -1296,11 +1571,12 @@ var OAuthHelpersImpl = class {
|
|
|
1296
1571
|
const resource = parseResourceParameter(resourceParam);
|
|
1297
1572
|
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
1298
1573
|
if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
|
|
1574
|
+
if (codeChallengeMethod === "plain" && this.provider.options.allowPlainPKCE === false) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
|
|
1299
1575
|
if (clientId) {
|
|
1300
1576
|
const clientInfo = await this.lookupClient(clientId);
|
|
1301
1577
|
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
1302
1578
|
if (clientInfo && redirectUri) {
|
|
1303
|
-
if (!clientInfo.redirectUris
|
|
1579
|
+
if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
|
|
1304
1580
|
}
|
|
1305
1581
|
}
|
|
1306
1582
|
return {
|
|
@@ -1333,7 +1609,7 @@ var OAuthHelpersImpl = class {
|
|
|
1333
1609
|
const { clientId, redirectUri } = options.request;
|
|
1334
1610
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
1335
1611
|
const clientInfo = await this.lookupClient(clientId);
|
|
1336
|
-
if (!clientInfo || !clientInfo.redirectUris
|
|
1612
|
+
if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
1337
1613
|
const grantId = generateRandomString(16);
|
|
1338
1614
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
1339
1615
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -1365,6 +1641,7 @@ var OAuthHelpersImpl = class {
|
|
|
1365
1641
|
createdAt: now,
|
|
1366
1642
|
expiresAt: accessTokenExpiresAt,
|
|
1367
1643
|
audience,
|
|
1644
|
+
scope: options.scope,
|
|
1368
1645
|
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
1369
1646
|
grant: {
|
|
1370
1647
|
clientId: options.request.clientId,
|
|
@@ -1428,7 +1705,11 @@ var OAuthHelpersImpl = class {
|
|
|
1428
1705
|
tosUri: clientInfo.tosUri,
|
|
1429
1706
|
jwksUri: clientInfo.jwksUri,
|
|
1430
1707
|
contacts: clientInfo.contacts,
|
|
1431
|
-
grantTypes: clientInfo.grantTypes || [
|
|
1708
|
+
grantTypes: clientInfo.grantTypes || [
|
|
1709
|
+
GrantType.AUTHORIZATION_CODE,
|
|
1710
|
+
GrantType.REFRESH_TOKEN,
|
|
1711
|
+
...this.provider.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
1712
|
+
],
|
|
1432
1713
|
responseTypes: clientInfo.responseTypes || ["code"],
|
|
1433
1714
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
1434
1715
|
tokenEndpointAuthMethod
|
|
@@ -1570,6 +1851,19 @@ var OAuthHelpersImpl = class {
|
|
|
1570
1851
|
async unwrapToken(token) {
|
|
1571
1852
|
return await this.provider.unwrapToken(token, this.env);
|
|
1572
1853
|
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
1856
|
+
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
1857
|
+
* @param options - Options for token exchange including subject token and optional modifications
|
|
1858
|
+
* @returns Promise resolving to token response with new access token
|
|
1859
|
+
*/
|
|
1860
|
+
async exchangeToken(options) {
|
|
1861
|
+
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
1862
|
+
if (!tokenSummary) throw new Error("Invalid or expired subject token");
|
|
1863
|
+
const clientInfo = await this.lookupClient(tokenSummary.grant.clientId);
|
|
1864
|
+
if (!clientInfo) throw new Error("Client not found");
|
|
1865
|
+
return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
|
|
1866
|
+
}
|
|
1573
1867
|
};
|
|
1574
1868
|
/**
|
|
1575
1869
|
* Default export of the OAuth provider
|
|
@@ -1578,4 +1872,4 @@ var OAuthHelpersImpl = class {
|
|
|
1578
1872
|
var oauth_provider_default = OAuthProvider;
|
|
1579
1873
|
|
|
1580
1874
|
//#endregion
|
|
1581
|
-
export { OAuthProvider, oauth_provider_default as default };
|
|
1875
|
+
export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/workers-oauth-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "OAuth provider for Cloudflare Workers",
|
|
5
5
|
"main": "dist/oauth-provider.js",
|
|
6
6
|
"types": "dist/oauth-provider.d.ts",
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
"pkg-pr-new": "^0.0.62",
|
|
32
32
|
"prettier": "^3.7.4",
|
|
33
33
|
"tsdown": "^0.18.1",
|
|
34
|
-
"tsx": "^4.21.0",
|
|
35
34
|
"typescript": "^5.9.3",
|
|
36
35
|
"vitest": "^3.2.4"
|
|
37
36
|
},
|