@cloudflare/workers-oauth-provider 0.4.0 → 0.6.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 +85 -3
- package/dist/oauth-provider.d.ts +177 -2
- package/dist/oauth-provider.js +369 -84
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,15 +96,21 @@ export default new OAuthProvider({
|
|
|
96
96
|
disallowPublicClientRegistration: false,
|
|
97
97
|
|
|
98
98
|
// Optional: Time-to-live for refresh tokens in seconds.
|
|
99
|
-
//
|
|
99
|
+
// Defaults to 30 days (2,592,000 seconds).
|
|
100
100
|
// Set to 0 to disable refresh tokens (only access tokens will be issued).
|
|
101
|
-
//
|
|
102
|
-
refreshTokenTTL: 2592000, // 30 days
|
|
101
|
+
// Set to `undefined` explicitly for refresh tokens that never expire.
|
|
102
|
+
refreshTokenTTL: 2592000, // 30 days (the default)
|
|
103
103
|
|
|
104
104
|
// Optional: Time-to-live for access tokens in seconds.
|
|
105
105
|
// Defaults to 1 hour (3600 seconds) if not specified.
|
|
106
106
|
accessTokenTTL: 3600,
|
|
107
107
|
|
|
108
|
+
// Optional: Time-to-live for dynamically registered clients in seconds.
|
|
109
|
+
// Defaults to 90 days (7,776,000 seconds).
|
|
110
|
+
// Clients created via OAuthHelpers.createClient() are not affected.
|
|
111
|
+
// Set to `undefined` explicitly for clients that never expire.
|
|
112
|
+
clientRegistrationTTL: 7776000, // 90 days (the default)
|
|
113
|
+
|
|
108
114
|
// Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
109
115
|
// When false, the token exchange grant type will not be advertised in metadata
|
|
110
116
|
// and token exchange requests will be rejected.
|
|
@@ -226,6 +232,9 @@ The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some me
|
|
|
226
232
|
- Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code).
|
|
227
233
|
- List all active authorization grants for a particular user.
|
|
228
234
|
- Revoke (delete) an authorization grant.
|
|
235
|
+
- Purge expired and orphaned data from the KV namespace.
|
|
236
|
+
|
|
237
|
+
Note that `deleteClient()` cascades: it revokes all grants (and their associated tokens) for the deleted client across all users.
|
|
229
238
|
|
|
230
239
|
See the `OAuthHelpers` interface definition for full API details.
|
|
231
240
|
|
|
@@ -297,6 +306,52 @@ The `accessTokenTTL` override is particularly useful when the application is als
|
|
|
297
306
|
|
|
298
307
|
The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
|
|
299
308
|
|
|
309
|
+
### Reporting errors from the callback
|
|
310
|
+
|
|
311
|
+
Throw `OAuthError` from `tokenExchangeCallback` to return a structured OAuth `/token` error (`{ error, error_description }`) instead of a generic 500:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { OAuthError, OAuthProvider } from '@cloudflare/workers-oauth-provider';
|
|
315
|
+
|
|
316
|
+
new OAuthProvider({
|
|
317
|
+
// …
|
|
318
|
+
tokenExchangeCallback: async (options) => {
|
|
319
|
+
if (options.grantType === 'refresh_token') {
|
|
320
|
+
return { newProps: await refreshUpstream(options.props) };
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
async function refreshUpstream(props) {
|
|
326
|
+
const res = await fetch(/* upstream token endpoint */);
|
|
327
|
+
|
|
328
|
+
if (res.status === 401) {
|
|
329
|
+
throw new OAuthError('invalid_grant', {
|
|
330
|
+
description: 'upstream refresh token is invalid',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (res.status === 429) {
|
|
335
|
+
throw new OAuthError('temporarily_unavailable', {
|
|
336
|
+
description: 'upstream rate limited',
|
|
337
|
+
statusCode: 429,
|
|
338
|
+
headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return await res.json();
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`OAuthError(code, options)` takes:
|
|
347
|
+
|
|
348
|
+
- `code` — OAuth error code returned in the `error` field. This may be a standard code (`OAuthTokenErrorCode`) or an application-defined string.
|
|
349
|
+
- `options.description` — human-readable text returned in `error_description`.
|
|
350
|
+
- `options.statusCode` — HTTP status code (default `400`).
|
|
351
|
+
- `options.headers` — additional response headers, such as `Retry-After` for transient failures. There is no implicit `Retry-After` default for callback-thrown errors.
|
|
352
|
+
|
|
353
|
+
Only `OAuthError` from this package is converted into a structured `/token` response. Plain errors, plain objects with a `code` field, and app-local error classes continue to surface as 500s so unexpected failures stay visible. Import `OAuthError` from `@cloudflare/workers-oauth-provider` rather than copying or re-implementing it.
|
|
354
|
+
|
|
300
355
|
## Custom Error Responses
|
|
301
356
|
|
|
302
357
|
By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
|
|
@@ -326,6 +381,33 @@ new OAuthProvider({
|
|
|
326
381
|
|
|
327
382
|
By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
|
|
328
383
|
|
|
384
|
+
## KV Namespace Cleanup
|
|
385
|
+
|
|
386
|
+
The library uses KV TTLs to automatically expire access tokens, refresh tokens (grants), and dynamically registered clients. As defense-in-depth, the library also provides a `purgeExpiredData()` method that cleans up orphaned and expired records. This is designed to be called from a [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) (scheduled handler):
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
const oauthProvider = new OAuthProvider({
|
|
390
|
+
// ... options ...
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
export default {
|
|
394
|
+
fetch(request, env, ctx) {
|
|
395
|
+
return oauthProvider.fetch(request, env, ctx);
|
|
396
|
+
},
|
|
397
|
+
async scheduled(event, env, ctx) {
|
|
398
|
+
const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
|
|
399
|
+
console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
|
|
405
|
+
|
|
406
|
+
1. **Grant sweep**: Removes orphaned grants (whose client no longer exists) and expired grants.
|
|
407
|
+
2. **Token sweep**: Removes orphaned tokens (whose grant no longer exists).
|
|
408
|
+
|
|
409
|
+
Call it repeatedly via a cron trigger — deleted records disappear from KV, so subsequent invocations naturally process fresh records without needing a persisted cursor. The `result.done` field indicates whether the full key space was scanned in this invocation.
|
|
410
|
+
|
|
329
411
|
## Protected Resource Metadata (RFC 9728)
|
|
330
412
|
|
|
331
413
|
The library automatically serves a `/.well-known/oauth-protected-resource` endpoint. By default, it uses the request origin as the resource identifier and the token endpoint's origin as the authorization server. You can customize this with the `resourceMetadata` option:
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ type WorkerEntrypointWithFetch<Env = Cloudflare.Env> = WorkerEntrypoint<Env> & {
|
|
|
20
20
|
/**
|
|
21
21
|
* Configuration options for the OAuth Provider
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* Registered OAuth 2.0 error codes that the `/token` endpoint may return.
|
|
25
|
+
*
|
|
26
|
+
* Union of:
|
|
27
|
+
* - RFC 6749 §5.2 (token endpoint)
|
|
28
|
+
* - RFC 6750 §3.1 (bearer / resource server) — included so callbacks
|
|
29
|
+
* doing audience validation can use them
|
|
30
|
+
* - RFC 8693 §2.2.2 (token exchange)
|
|
31
|
+
*/
|
|
32
|
+
type OAuthTokenErrorCode = 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope' | 'invalid_token' | 'insufficient_scope' | 'invalid_target' | 'server_error' | 'temporarily_unavailable';
|
|
23
33
|
/**
|
|
24
34
|
* Result of a token exchange callback function.
|
|
25
35
|
* Allows updating the props stored in both the access token and the grant.
|
|
@@ -178,10 +188,20 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
178
188
|
accessTokenTTL?: number;
|
|
179
189
|
/**
|
|
180
190
|
* Time-to-live for refresh tokens in seconds.
|
|
181
|
-
*
|
|
191
|
+
* Defaults to 30 days (2,592,000 seconds).
|
|
192
|
+
* Set to 0 to disable refresh tokens entirely.
|
|
193
|
+
* Set to `undefined` explicitly for refresh tokens that never expire.
|
|
182
194
|
* For example: 3600 = 1 hour, 2592000 = 30 days
|
|
183
195
|
*/
|
|
184
196
|
refreshTokenTTL?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Time-to-live for dynamically registered clients in seconds.
|
|
199
|
+
* Defaults to 90 days (7,776,000 seconds).
|
|
200
|
+
* Clients created via the DCR endpoint will automatically expire after this duration.
|
|
201
|
+
* Clients created via `OAuthHelpers.createClient()` are not affected by this setting.
|
|
202
|
+
* Set to `undefined` explicitly for clients that never expire.
|
|
203
|
+
*/
|
|
204
|
+
clientRegistrationTTL?: number;
|
|
185
205
|
/**
|
|
186
206
|
* List of scopes supported by this OAuth provider.
|
|
187
207
|
* If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
|
|
@@ -375,6 +395,22 @@ interface OAuthHelpers {
|
|
|
375
395
|
* @returns Promise resolving to token response with new access token
|
|
376
396
|
*/
|
|
377
397
|
exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
|
|
398
|
+
/**
|
|
399
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
400
|
+
* Designed to be called from a scheduled handler (Cron Trigger) for periodic cleanup.
|
|
401
|
+
* Processes records in configurable batches to stay within Cloudflare's subrequest limits.
|
|
402
|
+
*
|
|
403
|
+
* Performs two sweep phases:
|
|
404
|
+
* 1. Grant sweep: removes orphaned grants (client deleted) and expired grants (defense-in-depth for KV TTL)
|
|
405
|
+
* 2. Token sweep: removes orphaned tokens (grant deleted) as defense-in-depth
|
|
406
|
+
*
|
|
407
|
+
* Safe to call repeatedly — deleted records disappear from KV, so subsequent invocations
|
|
408
|
+
* naturally process fresh records without needing a persisted cursor.
|
|
409
|
+
*
|
|
410
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
411
|
+
* @returns Statistics about what was checked and purged, and whether the full scan completed
|
|
412
|
+
*/
|
|
413
|
+
purgeExpiredData(options?: PurgeOptions): Promise<PurgeResult>;
|
|
378
414
|
}
|
|
379
415
|
/**
|
|
380
416
|
* Options for token exchange operations (RFC 8693)
|
|
@@ -742,6 +778,55 @@ interface ListResult<T> {
|
|
|
742
778
|
*/
|
|
743
779
|
cursor?: string;
|
|
744
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* Options for the purgeExpiredData garbage collection method
|
|
783
|
+
*/
|
|
784
|
+
interface PurgeOptions {
|
|
785
|
+
/**
|
|
786
|
+
* Maximum number of KV keys to check per phase (grants and tokens) per invocation.
|
|
787
|
+
* Each phase (grant sweep, token sweep) gets its own budget of this size.
|
|
788
|
+
* Keep this conservative to stay within Cloudflare's 1000 subrequest limit per invocation,
|
|
789
|
+
* since each checked key requires at least one KV read, and orphaned grants trigger
|
|
790
|
+
* additional KV operations via revokeGrant().
|
|
791
|
+
* Defaults to 50.
|
|
792
|
+
*/
|
|
793
|
+
batchSize?: number;
|
|
794
|
+
/**
|
|
795
|
+
* Whether to purge orphaned grants whose client no longer exists in KV.
|
|
796
|
+
* Grants for CIMD (Client ID Metadata Document) clients are always skipped
|
|
797
|
+
* since those clients are not stored in KV.
|
|
798
|
+
* Defaults to true.
|
|
799
|
+
*/
|
|
800
|
+
purgeOrphanedGrants?: boolean;
|
|
801
|
+
/**
|
|
802
|
+
* Whether to purge expired grants as defense-in-depth for KV TTL.
|
|
803
|
+
* Normally KV auto-deletes expired entries, but this catches any stragglers.
|
|
804
|
+
* Defaults to true.
|
|
805
|
+
*/
|
|
806
|
+
purgeExpiredGrants?: boolean;
|
|
807
|
+
/**
|
|
808
|
+
* Whether to purge orphaned tokens whose grant no longer exists.
|
|
809
|
+
* Tokens already auto-expire via KV TTL (default 1 hour), so this is
|
|
810
|
+
* defense-in-depth for partial revokeGrant() failures.
|
|
811
|
+
* Defaults to true.
|
|
812
|
+
*/
|
|
813
|
+
purgeOrphanedTokens?: boolean;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Result of a purgeExpiredData garbage collection invocation
|
|
817
|
+
*/
|
|
818
|
+
interface PurgeResult {
|
|
819
|
+
/** Number of grant records checked in this invocation */
|
|
820
|
+
grantsChecked: number;
|
|
821
|
+
/** Number of grant records purged (orphaned or expired) */
|
|
822
|
+
grantsPurged: number;
|
|
823
|
+
/** Number of token records checked in this invocation */
|
|
824
|
+
tokensChecked: number;
|
|
825
|
+
/** Number of token records purged (orphaned) */
|
|
826
|
+
tokensPurged: number;
|
|
827
|
+
/** True if the full key space was scanned in this invocation (both grants and tokens) */
|
|
828
|
+
done: boolean;
|
|
829
|
+
}
|
|
745
830
|
/**
|
|
746
831
|
* Public representation of a grant, with sensitive data removed
|
|
747
832
|
* Used for list operations where the complete grant data isn't needed
|
|
@@ -797,6 +882,15 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
|
797
882
|
* @returns A Promise resolving to an HTTP Response
|
|
798
883
|
*/
|
|
799
884
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
|
|
885
|
+
/**
|
|
886
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
887
|
+
* Can be called directly from a scheduled handler without needing a request context.
|
|
888
|
+
*
|
|
889
|
+
* @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
|
|
890
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
891
|
+
* @returns Statistics about what was checked and purged
|
|
892
|
+
*/
|
|
893
|
+
purgeExpiredData(env: Env, options?: PurgeOptions): Promise<PurgeResult>;
|
|
800
894
|
}
|
|
801
895
|
/**
|
|
802
896
|
* Gets OAuthHelpers for the given environment
|
|
@@ -805,5 +899,86 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
|
805
899
|
* @returns An instance of OAuthHelpers
|
|
806
900
|
*/
|
|
807
901
|
declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
|
|
902
|
+
/**
|
|
903
|
+
* Error class for OAuth operations
|
|
904
|
+
* Carries OAuth error code and description for proper error responses
|
|
905
|
+
*/
|
|
906
|
+
/**
|
|
907
|
+
* Options accepted by the {@link OAuthError} constructor.
|
|
908
|
+
*/
|
|
909
|
+
interface OAuthErrorOptions {
|
|
910
|
+
/**
|
|
911
|
+
* Human-readable text returned in the `error_description` field.
|
|
912
|
+
*/
|
|
913
|
+
description: string;
|
|
914
|
+
/**
|
|
915
|
+
* HTTP status code for the error response. Defaults to `400`.
|
|
916
|
+
*/
|
|
917
|
+
statusCode?: number;
|
|
918
|
+
/**
|
|
919
|
+
* Additional response headers.
|
|
920
|
+
*
|
|
921
|
+
* For transient failures (e.g. upstream rate limits), set
|
|
922
|
+
* `Retry-After` here so well-behaved clients back off instead of
|
|
923
|
+
* retry-storming. Per RFC 7231 §7.1.3 the value may be either a
|
|
924
|
+
* number of seconds or an HTTP-date.
|
|
925
|
+
*/
|
|
926
|
+
headers?: Record<string, string>;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Structured OAuth 2.0 error.
|
|
930
|
+
*
|
|
931
|
+
* Throw from a `tokenExchangeCallback` (or any code it calls — the error
|
|
932
|
+
* propagates naturally up through deep call stacks) to surface a standard
|
|
933
|
+
* `/token` error response (`{ error, error_description }`) instead of a
|
|
934
|
+
* generic `500 Internal Server Error`.
|
|
935
|
+
*
|
|
936
|
+
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
937
|
+
* a 500 so unexpected failures remain visible — the provider does not
|
|
938
|
+
* catch-everything-and-return-400.
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* ```ts
|
|
942
|
+
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
943
|
+
*
|
|
944
|
+
* tokenExchangeCallback: async (options) => {
|
|
945
|
+
* if (options.grantType === 'refresh_token') {
|
|
946
|
+
* // refreshUpstream() may throw OAuthError from any depth
|
|
947
|
+
* return { newProps: await refreshUpstream(options.props) };
|
|
948
|
+
* }
|
|
949
|
+
* }
|
|
950
|
+
*
|
|
951
|
+
* async function refreshUpstream(props) {
|
|
952
|
+
* const res = await fetch(...);
|
|
953
|
+
* if (res.status === 401) {
|
|
954
|
+
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
955
|
+
* }
|
|
956
|
+
* if (res.status === 429) {
|
|
957
|
+
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
958
|
+
* throw new OAuthError('temporarily_unavailable', {
|
|
959
|
+
* description: 'upstream rate limited',
|
|
960
|
+
* statusCode: 429,
|
|
961
|
+
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
962
|
+
* });
|
|
963
|
+
* }
|
|
964
|
+
* return await res.json();
|
|
965
|
+
* }
|
|
966
|
+
* ```
|
|
967
|
+
*/
|
|
968
|
+
declare class OAuthError extends Error {
|
|
969
|
+
/** OAuth 2.0 error code. */
|
|
970
|
+
readonly code: string;
|
|
971
|
+
/** Options controlling the OAuth error response. */
|
|
972
|
+
readonly options: OAuthErrorOptions & {
|
|
973
|
+
statusCode: number;
|
|
974
|
+
};
|
|
975
|
+
/** Human-readable description sent in the `error_description` field. */
|
|
976
|
+
readonly description: string;
|
|
977
|
+
/** HTTP status code for the error response. */
|
|
978
|
+
readonly statusCode: number;
|
|
979
|
+
/** Additional response headers. */
|
|
980
|
+
readonly headers?: Record<string, string>;
|
|
981
|
+
constructor(code: string, options: OAuthErrorOptions);
|
|
982
|
+
}
|
|
808
983
|
//#endregion
|
|
809
|
-
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
|
984
|
+
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -45,6 +45,17 @@ var OAuthProvider = class {
|
|
|
45
45
|
fetch(request, env, ctx) {
|
|
46
46
|
return this.#impl.fetch(request, env, ctx);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
50
|
+
* Can be called directly from a scheduled handler without needing a request context.
|
|
51
|
+
*
|
|
52
|
+
* @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
|
|
53
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
54
|
+
* @returns Statistics about what was checked and purged
|
|
55
|
+
*/
|
|
56
|
+
purgeExpiredData(env, options) {
|
|
57
|
+
return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
|
|
58
|
+
}
|
|
48
59
|
};
|
|
49
60
|
/**
|
|
50
61
|
* Gets OAuthHelpers for the given environment
|
|
@@ -94,6 +105,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
94
105
|
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
95
106
|
this.options = {
|
|
96
107
|
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
108
|
+
refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
|
|
109
|
+
clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
|
|
97
110
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
98
111
|
...options
|
|
99
112
|
};
|
|
@@ -272,10 +285,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
272
285
|
* @returns Promise with parsed body and client info, or error response
|
|
273
286
|
*/
|
|
274
287
|
async parseTokenEndpointRequest(request, env) {
|
|
275
|
-
if (request.method !== "POST") return this.createErrorResponse("invalid_request",
|
|
288
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
289
|
+
description: "Method not allowed",
|
|
290
|
+
statusCode: 405
|
|
291
|
+
});
|
|
276
292
|
let contentType = request.headers.get("Content-Type") || "";
|
|
277
293
|
let body = {};
|
|
278
|
-
if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request",
|
|
294
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", {
|
|
295
|
+
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
296
|
+
statusCode: 400
|
|
297
|
+
});
|
|
279
298
|
const formData = await request.formData();
|
|
280
299
|
for (const [key, value] of formData.entries()) {
|
|
281
300
|
const allValues = formData.getAll(key);
|
|
@@ -292,13 +311,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
292
311
|
clientId = body.client_id;
|
|
293
312
|
clientSecret = body.client_secret || "";
|
|
294
313
|
}
|
|
295
|
-
if (!clientId) return this.createErrorResponse("invalid_client",
|
|
314
|
+
if (!clientId) return this.createErrorResponse("invalid_client", {
|
|
315
|
+
description: "Client ID is required",
|
|
316
|
+
statusCode: 401
|
|
317
|
+
});
|
|
296
318
|
const clientInfo = await this.getClient(env, clientId);
|
|
297
|
-
if (!clientInfo) return this.createErrorResponse("invalid_client",
|
|
319
|
+
if (!clientInfo) return this.createErrorResponse("invalid_client", {
|
|
320
|
+
description: "Client not found",
|
|
321
|
+
statusCode: 401
|
|
322
|
+
});
|
|
298
323
|
if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
|
|
299
|
-
if (!clientSecret) return this.createErrorResponse("invalid_client",
|
|
300
|
-
|
|
301
|
-
|
|
324
|
+
if (!clientSecret) return this.createErrorResponse("invalid_client", {
|
|
325
|
+
description: "Client authentication failed: missing client_secret",
|
|
326
|
+
statusCode: 401
|
|
327
|
+
});
|
|
328
|
+
if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
|
|
329
|
+
description: "Client authentication failed: client has no registered secret",
|
|
330
|
+
statusCode: 401
|
|
331
|
+
});
|
|
332
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
|
|
333
|
+
description: "Client authentication failed: invalid client_secret",
|
|
334
|
+
statusCode: 401
|
|
335
|
+
});
|
|
302
336
|
}
|
|
303
337
|
return {
|
|
304
338
|
body,
|
|
@@ -428,11 +462,31 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
428
462
|
* @returns Response with token data or error
|
|
429
463
|
*/
|
|
430
464
|
async handleTokenRequest(body, clientInfo, env) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
465
|
+
try {
|
|
466
|
+
const grantType = body.grant_type;
|
|
467
|
+
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
468
|
+
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
469
|
+
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
470
|
+
else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const response = this.createOAuthErrorResponse(error);
|
|
473
|
+
if (response) return response;
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Build a structured OAuth `/token` error response from an OAuth error.
|
|
479
|
+
*
|
|
480
|
+
* The supported form is throwing this package's exported `OAuthError`.
|
|
481
|
+
* Anything else is re-thrown so unexpected failures still surface as 500s.
|
|
482
|
+
*
|
|
483
|
+
* Use `headers['Retry-After']` for rate-limit / transient-failure backoff
|
|
484
|
+
* hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
|
|
485
|
+
* HTTP-date is allowed).
|
|
486
|
+
*/
|
|
487
|
+
createOAuthErrorResponse(error) {
|
|
488
|
+
if (!(error instanceof OAuthError)) return void 0;
|
|
489
|
+
return this.createErrorResponse(error.code, error.options);
|
|
436
490
|
}
|
|
437
491
|
/**
|
|
438
492
|
* Handles the authorization code grant type
|
|
@@ -446,27 +500,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
446
500
|
const code = body.code;
|
|
447
501
|
const redirectUri = body.redirect_uri;
|
|
448
502
|
const codeVerifier = body.code_verifier;
|
|
449
|
-
if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
|
|
503
|
+
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
|
|
450
504
|
const codeParts = code.split(":");
|
|
451
|
-
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
|
|
505
|
+
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
|
|
452
506
|
const [userId, grantId, _] = codeParts;
|
|
453
507
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
454
508
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
455
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
|
|
509
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
|
|
456
510
|
if (!grantData.authCodeId) {
|
|
457
511
|
try {
|
|
458
512
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
459
513
|
} catch {}
|
|
460
|
-
return this.createErrorResponse("invalid_grant", "Authorization code already used");
|
|
514
|
+
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
461
515
|
}
|
|
462
|
-
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
|
|
463
|
-
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
516
|
+
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
|
|
517
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
464
518
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
465
|
-
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
|
|
466
|
-
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
|
|
467
|
-
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
|
|
519
|
+
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
520
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
521
|
+
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
|
|
468
522
|
if (isPkceEnabled) {
|
|
469
|
-
if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
|
|
523
|
+
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
|
|
470
524
|
let calculatedChallenge;
|
|
471
525
|
if (grantData.codeChallengeMethod === "S256") {
|
|
472
526
|
const data = new TextEncoder().encode(codeVerifier);
|
|
@@ -474,7 +528,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
474
528
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
475
529
|
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
476
530
|
} else calculatedChallenge = codeVerifier;
|
|
477
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
|
|
531
|
+
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
|
|
478
532
|
}
|
|
479
533
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
480
534
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
@@ -541,10 +595,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
541
595
|
if (body.resource && grantData.resource) {
|
|
542
596
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
543
597
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
544
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
598
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
545
599
|
}
|
|
546
600
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
547
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
601
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
548
602
|
const tokenResponse = {
|
|
549
603
|
access_token: await this.createAccessToken({
|
|
550
604
|
userId,
|
|
@@ -575,20 +629,20 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
575
629
|
*/
|
|
576
630
|
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
577
631
|
const refreshToken = body.refresh_token;
|
|
578
|
-
if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
|
|
632
|
+
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
|
|
579
633
|
const tokenParts = refreshToken.split(":");
|
|
580
|
-
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
|
|
634
|
+
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
|
|
581
635
|
const [userId, grantId, _] = tokenParts;
|
|
582
636
|
const providedTokenHash = await generateTokenId(refreshToken);
|
|
583
637
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
584
638
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
585
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
|
|
639
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
|
|
586
640
|
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
587
641
|
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
588
|
-
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
|
|
589
|
-
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
642
|
+
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
|
|
643
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
590
644
|
if (grantData.expiresAt !== void 0) {
|
|
591
|
-
if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
|
|
645
|
+
if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
592
646
|
}
|
|
593
647
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
594
648
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
@@ -623,7 +677,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
623
677
|
}
|
|
624
678
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
625
679
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
626
|
-
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
|
|
680
|
+
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
|
|
627
681
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
628
682
|
}
|
|
629
683
|
if (grantPropsChanged) {
|
|
@@ -662,10 +716,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
662
716
|
if (body.resource && grantData.resource) {
|
|
663
717
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
664
718
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
665
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
719
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
666
720
|
}
|
|
667
721
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
668
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
722
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
669
723
|
const accessTokenData = {
|
|
670
724
|
id: accessTokenId,
|
|
671
725
|
grantId,
|
|
@@ -681,7 +735,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
681
735
|
encryptedProps: encryptedAccessTokenProps
|
|
682
736
|
}
|
|
683
737
|
};
|
|
684
|
-
|
|
738
|
+
try {
|
|
739
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
685
744
|
const tokenResponse = {
|
|
686
745
|
access_token: newAccessToken,
|
|
687
746
|
token_type: "bearer",
|
|
@@ -709,10 +768,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
709
768
|
*/
|
|
710
769
|
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
711
770
|
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
712
|
-
if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
|
|
771
|
+
if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
|
|
713
772
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
714
773
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
715
|
-
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
774
|
+
if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
|
|
716
775
|
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
717
776
|
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
718
777
|
let newAudience = tokenSummary.audience;
|
|
@@ -720,21 +779,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
720
779
|
if (grantData.resource) {
|
|
721
780
|
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
722
781
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
723
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
782
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
724
783
|
}
|
|
725
784
|
const parsedResource = parseResourceParameter(requestedResource);
|
|
726
|
-
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
785
|
+
if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
727
786
|
newAudience = parsedResource;
|
|
728
787
|
}
|
|
729
788
|
const now = Math.floor(Date.now() / 1e3);
|
|
730
789
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
731
790
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
732
791
|
if (expiresIn !== void 0) {
|
|
733
|
-
if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
|
|
792
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
|
|
734
793
|
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
735
794
|
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
736
795
|
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
737
|
-
if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
|
|
796
|
+
if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
|
|
738
797
|
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
739
798
|
let accessTokenEncryptionKey = encryptionKey;
|
|
740
799
|
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
@@ -798,25 +857,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
798
857
|
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
799
858
|
const requestedScope = body.scope;
|
|
800
859
|
const requestedResource = body.resource;
|
|
801
|
-
if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
|
|
802
|
-
if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
|
|
803
|
-
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
|
|
804
|
-
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
|
|
860
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
|
|
861
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
|
|
862
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
|
|
863
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
|
|
805
864
|
let requestedScopes;
|
|
806
865
|
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
807
866
|
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
808
|
-
else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
|
|
867
|
+
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
|
|
809
868
|
let expiresIn;
|
|
810
869
|
if (body.expires_in !== void 0) {
|
|
811
870
|
const requestedTTL = parseInt(body.expires_in, 10);
|
|
812
|
-
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
|
|
871
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
|
|
813
872
|
expiresIn = requestedTTL;
|
|
814
873
|
}
|
|
815
874
|
try {
|
|
816
875
|
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
817
876
|
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
818
877
|
} catch (error) {
|
|
819
|
-
|
|
878
|
+
const response = this.createOAuthErrorResponse(error);
|
|
879
|
+
if (response) return response;
|
|
820
880
|
throw error;
|
|
821
881
|
}
|
|
822
882
|
}
|
|
@@ -838,7 +898,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
838
898
|
*/
|
|
839
899
|
async revokeToken(body, env) {
|
|
840
900
|
const token = body.token;
|
|
841
|
-
if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
|
|
901
|
+
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
|
|
842
902
|
const tokenParts = token.split(":");
|
|
843
903
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
844
904
|
const [userId, grantId, _] = tokenParts;
|
|
@@ -896,20 +956,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
896
956
|
* @returns Response with client registration data or error
|
|
897
957
|
*/
|
|
898
958
|
async handleClientRegistration(request, env) {
|
|
899
|
-
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented",
|
|
900
|
-
|
|
901
|
-
|
|
959
|
+
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
|
|
960
|
+
description: "Client registration is not enabled",
|
|
961
|
+
statusCode: 501
|
|
962
|
+
});
|
|
963
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
964
|
+
description: "Method not allowed",
|
|
965
|
+
statusCode: 405
|
|
966
|
+
});
|
|
967
|
+
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
|
|
968
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
969
|
+
statusCode: 413
|
|
970
|
+
});
|
|
902
971
|
let clientMetadata;
|
|
903
972
|
try {
|
|
904
973
|
const text = await request.text();
|
|
905
|
-
if (text.length > 1048576) return this.createErrorResponse("invalid_request",
|
|
974
|
+
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
975
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
976
|
+
statusCode: 413
|
|
977
|
+
});
|
|
906
978
|
clientMetadata = JSON.parse(text);
|
|
907
979
|
} catch (error) {
|
|
908
|
-
return this.createErrorResponse("invalid_request",
|
|
980
|
+
return this.createErrorResponse("invalid_request", {
|
|
981
|
+
description: "Invalid JSON payload",
|
|
982
|
+
statusCode: 400
|
|
983
|
+
});
|
|
909
984
|
}
|
|
910
985
|
const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
|
|
911
986
|
const isPublicClient = authMethod === "none";
|
|
912
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
|
|
987
|
+
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
913
988
|
const clientId = generateRandomString(16);
|
|
914
989
|
let clientSecret;
|
|
915
990
|
let hashedSecret;
|
|
@@ -943,9 +1018,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
943
1018
|
};
|
|
944
1019
|
if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
|
|
945
1020
|
} catch (error) {
|
|
946
|
-
return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
|
|
1021
|
+
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
947
1022
|
}
|
|
948
|
-
|
|
1023
|
+
const clientKvOptions = {};
|
|
1024
|
+
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
1025
|
+
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
949
1026
|
const response = {
|
|
950
1027
|
client_id: clientInfo.clientId,
|
|
951
1028
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -964,7 +1041,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
964
1041
|
};
|
|
965
1042
|
if (clientSecret) {
|
|
966
1043
|
response.client_secret = clientSecret;
|
|
967
|
-
response.client_secret_expires_at = 0;
|
|
1044
|
+
response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
968
1045
|
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
969
1046
|
}
|
|
970
1047
|
return new Response(JSON.stringify(response), {
|
|
@@ -983,7 +1060,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
983
1060
|
const url = new URL(request.url);
|
|
984
1061
|
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
985
1062
|
const authHeader = request.headers.get("Authorization");
|
|
986
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token",
|
|
1063
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
|
|
1064
|
+
description: "Missing or invalid access token",
|
|
1065
|
+
statusCode: 401,
|
|
1066
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") }
|
|
1067
|
+
});
|
|
987
1068
|
const accessToken = authHeader.substring(7);
|
|
988
1069
|
const parts = accessToken.split(":");
|
|
989
1070
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
@@ -995,14 +1076,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
995
1076
|
const id = await generateTokenId(accessToken);
|
|
996
1077
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
997
1078
|
}
|
|
998
|
-
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token",
|
|
1079
|
+
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
|
|
1080
|
+
description: "Invalid access token",
|
|
1081
|
+
statusCode: 401,
|
|
1082
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
1083
|
+
});
|
|
999
1084
|
if (tokenData) {
|
|
1000
1085
|
const now = Math.floor(Date.now() / 1e3);
|
|
1001
|
-
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token",
|
|
1086
|
+
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
1087
|
+
description: "Access token expired",
|
|
1088
|
+
statusCode: 401,
|
|
1089
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
1090
|
+
});
|
|
1002
1091
|
if (tokenData.audience) {
|
|
1003
1092
|
const requestUrl = new URL(request.url);
|
|
1004
1093
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
1005
|
-
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token",
|
|
1094
|
+
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
1095
|
+
description: "Token audience does not match resource server",
|
|
1096
|
+
statusCode: 401,
|
|
1097
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
|
|
1098
|
+
});
|
|
1006
1099
|
}
|
|
1007
1100
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
1008
1101
|
} else if (this.options.resolveExternalToken) {
|
|
@@ -1011,17 +1104,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1011
1104
|
request,
|
|
1012
1105
|
env
|
|
1013
1106
|
});
|
|
1014
|
-
if (!ext) return this.createErrorResponse("invalid_token",
|
|
1107
|
+
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
1108
|
+
description: "Invalid access token",
|
|
1109
|
+
statusCode: 401,
|
|
1110
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
1111
|
+
});
|
|
1015
1112
|
if (ext.audience) {
|
|
1016
1113
|
const requestUrl = new URL(request.url);
|
|
1017
1114
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
1018
|
-
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token",
|
|
1115
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
1116
|
+
description: "Token audience does not match resource server",
|
|
1117
|
+
statusCode: 401,
|
|
1118
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
|
|
1119
|
+
});
|
|
1019
1120
|
}
|
|
1020
1121
|
ctx.props = ext.props;
|
|
1021
1122
|
}
|
|
1022
1123
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
1023
1124
|
const apiHandler = this.findApiHandlerForUrl(url);
|
|
1024
|
-
if (!apiHandler) return this.createErrorResponse("invalid_request",
|
|
1125
|
+
if (!apiHandler) return this.createErrorResponse("invalid_request", {
|
|
1126
|
+
description: "No handler found for API route",
|
|
1127
|
+
statusCode: 404
|
|
1128
|
+
});
|
|
1025
1129
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
1026
1130
|
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
1027
1131
|
}
|
|
@@ -1043,7 +1147,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1043
1147
|
*/
|
|
1044
1148
|
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
1045
1149
|
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
|
|
1046
|
-
|
|
1150
|
+
try {
|
|
1151
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
1154
|
+
throw error;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
throwRetryableTokenStorageErrorIfKvRateLimited(error) {
|
|
1158
|
+
if (!this.isKvRateLimitError(error)) return;
|
|
1159
|
+
throw new OAuthError("temporarily_unavailable", {
|
|
1160
|
+
description: "Token issuance is temporarily unavailable; retry shortly",
|
|
1161
|
+
statusCode: 429,
|
|
1162
|
+
headers: { "Retry-After": "30" }
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
isKvRateLimitError(error) {
|
|
1166
|
+
if (!(error instanceof Error)) return false;
|
|
1167
|
+
return /KV .*failed: 429 Too Many Requests/i.test(error.message) || /429 Too Many Requests/i.test(error.message);
|
|
1047
1168
|
}
|
|
1048
1169
|
/**
|
|
1049
1170
|
* Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
|
|
@@ -1100,7 +1221,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1100
1221
|
encryptedProps
|
|
1101
1222
|
}
|
|
1102
1223
|
};
|
|
1103
|
-
|
|
1224
|
+
try {
|
|
1225
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
1228
|
+
throw error;
|
|
1229
|
+
}
|
|
1104
1230
|
return accessToken;
|
|
1105
1231
|
}
|
|
1106
1232
|
/**
|
|
@@ -1123,7 +1249,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1123
1249
|
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
1124
1250
|
}
|
|
1125
1251
|
/**
|
|
1126
|
-
* Checks if a client_id is a CIMD URL (HTTPS with non-root path)
|
|
1252
|
+
* Checks if a client_id is a CIMD URL (HTTPS with non-root path).
|
|
1253
|
+
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
1127
1254
|
*/
|
|
1128
1255
|
isClientMetadataUrl(clientId) {
|
|
1129
1256
|
try {
|
|
@@ -1262,17 +1389,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1262
1389
|
/**
|
|
1263
1390
|
* Helper function to create OAuth error responses
|
|
1264
1391
|
* @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
|
|
1265
|
-
* @param
|
|
1266
|
-
* @param status - HTTP status code (default: 400)
|
|
1267
|
-
* @param headers - Additional headers to include
|
|
1392
|
+
* @param options - Error response options
|
|
1268
1393
|
* @returns A Response object with the error
|
|
1269
1394
|
*/
|
|
1270
|
-
createErrorResponse(code,
|
|
1395
|
+
createErrorResponse(code, options) {
|
|
1396
|
+
const { description } = options;
|
|
1397
|
+
const responseStatus = options.statusCode ?? 400;
|
|
1398
|
+
const responseHeaders = options.headers ?? {};
|
|
1271
1399
|
const customErrorResponse = this.options.onError?.({
|
|
1272
1400
|
code,
|
|
1273
1401
|
description,
|
|
1274
|
-
status,
|
|
1275
|
-
headers
|
|
1402
|
+
status: responseStatus,
|
|
1403
|
+
headers: responseHeaders
|
|
1276
1404
|
});
|
|
1277
1405
|
if (customErrorResponse) return customErrorResponse;
|
|
1278
1406
|
const body = JSON.stringify({
|
|
@@ -1280,23 +1408,66 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1280
1408
|
error_description: description
|
|
1281
1409
|
});
|
|
1282
1410
|
return new Response(body, {
|
|
1283
|
-
status,
|
|
1411
|
+
status: responseStatus,
|
|
1284
1412
|
headers: {
|
|
1285
1413
|
"Content-Type": "application/json",
|
|
1286
|
-
...
|
|
1414
|
+
...responseHeaders
|
|
1287
1415
|
}
|
|
1288
1416
|
});
|
|
1289
1417
|
}
|
|
1290
1418
|
};
|
|
1291
1419
|
/**
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1420
|
+
* Structured OAuth 2.0 error.
|
|
1421
|
+
*
|
|
1422
|
+
* Throw from a `tokenExchangeCallback` (or any code it calls — the error
|
|
1423
|
+
* propagates naturally up through deep call stacks) to surface a standard
|
|
1424
|
+
* `/token` error response (`{ error, error_description }`) instead of a
|
|
1425
|
+
* generic `500 Internal Server Error`.
|
|
1426
|
+
*
|
|
1427
|
+
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
1428
|
+
* a 500 so unexpected failures remain visible — the provider does not
|
|
1429
|
+
* catch-everything-and-return-400.
|
|
1430
|
+
*
|
|
1431
|
+
* @example
|
|
1432
|
+
* ```ts
|
|
1433
|
+
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
1434
|
+
*
|
|
1435
|
+
* tokenExchangeCallback: async (options) => {
|
|
1436
|
+
* if (options.grantType === 'refresh_token') {
|
|
1437
|
+
* // refreshUpstream() may throw OAuthError from any depth
|
|
1438
|
+
* return { newProps: await refreshUpstream(options.props) };
|
|
1439
|
+
* }
|
|
1440
|
+
* }
|
|
1441
|
+
*
|
|
1442
|
+
* async function refreshUpstream(props) {
|
|
1443
|
+
* const res = await fetch(...);
|
|
1444
|
+
* if (res.status === 401) {
|
|
1445
|
+
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
1446
|
+
* }
|
|
1447
|
+
* if (res.status === 429) {
|
|
1448
|
+
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
1449
|
+
* throw new OAuthError('temporarily_unavailable', {
|
|
1450
|
+
* description: 'upstream rate limited',
|
|
1451
|
+
* statusCode: 429,
|
|
1452
|
+
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
1453
|
+
* });
|
|
1454
|
+
* }
|
|
1455
|
+
* return await res.json();
|
|
1456
|
+
* }
|
|
1457
|
+
* ```
|
|
1294
1458
|
*/
|
|
1295
1459
|
var OAuthError = class extends Error {
|
|
1296
|
-
constructor(code,
|
|
1297
|
-
super(
|
|
1298
|
-
this.code = code;
|
|
1460
|
+
constructor(code, options) {
|
|
1461
|
+
super(options.description);
|
|
1299
1462
|
this.name = "OAuthError";
|
|
1463
|
+
this.code = code;
|
|
1464
|
+
this.options = {
|
|
1465
|
+
...options,
|
|
1466
|
+
statusCode: options.statusCode ?? 400
|
|
1467
|
+
};
|
|
1468
|
+
this.description = this.options.description;
|
|
1469
|
+
this.statusCode = this.options.statusCode;
|
|
1470
|
+
this.headers = this.options.headers;
|
|
1300
1471
|
}
|
|
1301
1472
|
};
|
|
1302
1473
|
/**
|
|
@@ -1304,6 +1475,19 @@ var OAuthError = class extends Error {
|
|
|
1304
1475
|
*/
|
|
1305
1476
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
1306
1477
|
/**
|
|
1478
|
+
* Default expiration time for refresh tokens (30 days in seconds)
|
|
1479
|
+
*/
|
|
1480
|
+
const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
|
|
1481
|
+
/**
|
|
1482
|
+
* Default expiration time for dynamically registered clients (90 days in seconds)
|
|
1483
|
+
*/
|
|
1484
|
+
const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
1485
|
+
/**
|
|
1486
|
+
* Default batch size for purgeExpiredData. Conservative to stay within
|
|
1487
|
+
* Cloudflare's 1000 subrequest limit per invocation.
|
|
1488
|
+
*/
|
|
1489
|
+
const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
1490
|
+
/**
|
|
1307
1491
|
* Length of generated token strings
|
|
1308
1492
|
*/
|
|
1309
1493
|
const TOKEN_LENGTH = 32;
|
|
@@ -1846,17 +2030,32 @@ var OAuthHelpersImpl = class {
|
|
|
1846
2030
|
};
|
|
1847
2031
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
1848
2032
|
else delete updatedClient.clientSecret;
|
|
1849
|
-
|
|
2033
|
+
const clientKvOptions = {};
|
|
2034
|
+
if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
|
|
2035
|
+
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
|
|
1850
2036
|
const response = { ...updatedClient };
|
|
1851
2037
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
1852
2038
|
return response;
|
|
1853
2039
|
}
|
|
1854
2040
|
/**
|
|
1855
|
-
* Deletes an OAuth client
|
|
2041
|
+
* Deletes an OAuth client and revokes all associated grants across all users.
|
|
1856
2042
|
* @param clientId - The ID of the client to delete
|
|
1857
2043
|
* @returns A Promise resolving when the deletion is confirmed.
|
|
1858
2044
|
*/
|
|
1859
2045
|
async deleteClient(clientId) {
|
|
2046
|
+
let cursor;
|
|
2047
|
+
let allProcessed = false;
|
|
2048
|
+
while (!allProcessed) {
|
|
2049
|
+
const listOptions = { prefix: "grant:" };
|
|
2050
|
+
if (cursor) listOptions.cursor = cursor;
|
|
2051
|
+
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
2052
|
+
for (const key of result.keys) {
|
|
2053
|
+
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
2054
|
+
if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
|
|
2055
|
+
}
|
|
2056
|
+
if (result.list_complete) allProcessed = true;
|
|
2057
|
+
else cursor = result.cursor;
|
|
2058
|
+
}
|
|
1860
2059
|
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
1861
2060
|
}
|
|
1862
2061
|
/**
|
|
@@ -1937,6 +2136,92 @@ var OAuthHelpersImpl = class {
|
|
|
1937
2136
|
if (!clientInfo) throw new Error("Client not found");
|
|
1938
2137
|
return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
|
|
1939
2138
|
}
|
|
2139
|
+
async purgeExpiredData(options) {
|
|
2140
|
+
const batchSize = options?.batchSize ?? DEFAULT_PURGE_BATCH_SIZE;
|
|
2141
|
+
const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
|
|
2142
|
+
const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
|
|
2143
|
+
const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
|
|
2144
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2145
|
+
const result = {
|
|
2146
|
+
grantsChecked: 0,
|
|
2147
|
+
grantsPurged: 0,
|
|
2148
|
+
tokensChecked: 0,
|
|
2149
|
+
tokensPurged: 0,
|
|
2150
|
+
done: false
|
|
2151
|
+
};
|
|
2152
|
+
if (purgeOrphanedGrants || purgeExpiredGrants) {
|
|
2153
|
+
const knownGoodClients = /* @__PURE__ */ new Set();
|
|
2154
|
+
const knownMissingClients = /* @__PURE__ */ new Set();
|
|
2155
|
+
let grantCursor;
|
|
2156
|
+
let grantsDone = false;
|
|
2157
|
+
while (!grantsDone && result.grantsChecked < batchSize) {
|
|
2158
|
+
const listOptions = {
|
|
2159
|
+
prefix: "grant:",
|
|
2160
|
+
limit: Math.min(1e3, batchSize - result.grantsChecked)
|
|
2161
|
+
};
|
|
2162
|
+
if (grantCursor) listOptions.cursor = grantCursor;
|
|
2163
|
+
const page = await this.env.OAUTH_KV.list(listOptions);
|
|
2164
|
+
for (const key of page.keys) {
|
|
2165
|
+
if (result.grantsChecked >= batchSize) break;
|
|
2166
|
+
result.grantsChecked++;
|
|
2167
|
+
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
2168
|
+
if (!grantData) continue;
|
|
2169
|
+
let shouldPurge = false;
|
|
2170
|
+
if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
|
|
2171
|
+
if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
|
|
2172
|
+
if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
|
|
2173
|
+
else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
|
|
2174
|
+
else {
|
|
2175
|
+
knownMissingClients.add(grantData.clientId);
|
|
2176
|
+
shouldPurge = true;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (shouldPurge) {
|
|
2180
|
+
await this.revokeGrant(grantData.id, grantData.userId);
|
|
2181
|
+
result.grantsPurged++;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (page.list_complete) grantsDone = true;
|
|
2185
|
+
else grantCursor = page.cursor;
|
|
2186
|
+
}
|
|
2187
|
+
if (!grantsDone) return result;
|
|
2188
|
+
}
|
|
2189
|
+
if (purgeOrphanedTokens) {
|
|
2190
|
+
const knownGoodGrants = /* @__PURE__ */ new Set();
|
|
2191
|
+
const knownMissingGrants = /* @__PURE__ */ new Set();
|
|
2192
|
+
let tokenCursor;
|
|
2193
|
+
let tokensDone = false;
|
|
2194
|
+
while (!tokensDone && result.tokensChecked < batchSize) {
|
|
2195
|
+
const listOptions = {
|
|
2196
|
+
prefix: "token:",
|
|
2197
|
+
limit: Math.min(1e3, batchSize - result.tokensChecked)
|
|
2198
|
+
};
|
|
2199
|
+
if (tokenCursor) listOptions.cursor = tokenCursor;
|
|
2200
|
+
const page = await this.env.OAUTH_KV.list(listOptions);
|
|
2201
|
+
for (const key of page.keys) {
|
|
2202
|
+
if (result.tokensChecked >= batchSize) break;
|
|
2203
|
+
result.tokensChecked++;
|
|
2204
|
+
const tokenData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
2205
|
+
if (!tokenData) continue;
|
|
2206
|
+
const grantKey = `grant:${tokenData.userId}:${tokenData.grantId}`;
|
|
2207
|
+
if (knownMissingGrants.has(grantKey)) {
|
|
2208
|
+
await this.env.OAUTH_KV.delete(key.name);
|
|
2209
|
+
result.tokensPurged++;
|
|
2210
|
+
} else if (!knownGoodGrants.has(grantKey)) if (await this.env.OAUTH_KV.get(grantKey)) knownGoodGrants.add(grantKey);
|
|
2211
|
+
else {
|
|
2212
|
+
knownMissingGrants.add(grantKey);
|
|
2213
|
+
await this.env.OAUTH_KV.delete(key.name);
|
|
2214
|
+
result.tokensPurged++;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
if (page.list_complete) tokensDone = true;
|
|
2218
|
+
else tokenCursor = page.cursor;
|
|
2219
|
+
}
|
|
2220
|
+
if (!tokensDone) return result;
|
|
2221
|
+
}
|
|
2222
|
+
result.done = true;
|
|
2223
|
+
return result;
|
|
2224
|
+
}
|
|
1940
2225
|
};
|
|
1941
2226
|
/**
|
|
1942
2227
|
* Default export of the OAuth provider
|
|
@@ -1945,4 +2230,4 @@ var OAuthHelpersImpl = class {
|
|
|
1945
2230
|
var oauth_provider_default = OAuthProvider;
|
|
1946
2231
|
|
|
1947
2232
|
//#endregion
|
|
1948
|
-
export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|
|
2233
|
+
export { GrantType, OAuthError, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|