diff --git a/src/workerd/api/global-scope.h b/src/workerd/api/global-scope.h index ffaf66fac9e..b4993060f66 100644 --- a/src/workerd/api/global-scope.h +++ b/src/workerd/api/global-scope.h @@ -239,6 +239,25 @@ class ExecutionContext: public jsg::Object { return js.undefined(); } + // Called by the host runtime (edgeworker) to set the Access context for this request. + // Must be called before the worker's handler is invoked. + // + // Unlike other ExecutionContext fields (props, version, exports) which are injected through the + // constructor, access uses a post-construction setter because the Access context is assembled by + // the host runtime after ExecutionContext construction but before handler invocation. The access + // data (audience claim, identity fetcher) originates from the Cloudflare Access integration + // pipeline and is not available during ExecutionContext construction in edgeworker. + void setAccess(jsg::Lock& js, jsg::JsRef value) { + access = kj::mv(value); + } + + jsg::JsValue getAccess(jsg::Lock& js) { + KJ_IF_SOME(a, access) { + return a.getHandle(js); + } + return js.undefined(); + } + JSG_RESOURCE_TYPE(ExecutionContext, CompatibilityFlags::Reader flags) { JSG_METHOD(waitUntil); JSG_METHOD(passThroughOnException); @@ -249,6 +268,9 @@ class ExecutionContext: public jsg::Object { if (flags.getEnableVersionApi()) { JSG_LAZY_INSTANCE_PROPERTY(version, getVersion); } + if (flags.getEnableCtxAccess()) { + JSG_LAZY_INSTANCE_PROPERTY(access, getAccess); + } if (flags.getWorkerdExperimental()) { // TODO(soon): Before making this generally available we need to: @@ -265,6 +287,11 @@ class ExecutionContext: public jsg::Object { } // TODO(soon): This is getting unwieldy. + // Note: `access` is included unconditionally in all TS_OVERRIDE branches (unlike `version` + // which is gated by enableVersionApi). This is intentional — adding another conditional would + // double the branch count (from 4 to 8). Since `access` is optional (`?`), the type is + // correct regardless of whether the flag is enabled (the property will be undefined at runtime + // when the flag is off or when setAccess() hasn't been called). if (flags.getEnableCtxExports()) { if (flags.getEnableVersionApi()) { JSG_TS_OVERRIDE( { @@ -276,11 +303,13 @@ class ExecutionContext: public jsg::Object { readonly key?: string; readonly override?: string; }; + readonly access?: CloudflareAccessContext; }); } else { JSG_TS_OVERRIDE( { readonly props: Props; readonly exports: Cloudflare.Exports; + readonly access?: CloudflareAccessContext; }); } } else { @@ -293,10 +322,12 @@ class ExecutionContext: public jsg::Object { readonly key?: string; readonly override?: string; }; + readonly access?: CloudflareAccessContext; }); } else { JSG_TS_OVERRIDE( { readonly props: Props; + readonly access?: CloudflareAccessContext; }); } } @@ -305,16 +336,19 @@ class ExecutionContext: public jsg::Object { void visitForMemoryInfo(jsg::MemoryTracker& tracker) const { tracker.trackField("props", props); tracker.trackField("version", version); + tracker.trackField("access", access); } private: jsg::JsRef exports; jsg::JsRef props; kj::Maybe> version; + kj::Maybe> access; void visitForGc(jsg::GcVisitor& visitor) { visitor.visit(props); visitor.visit(version); + visitor.visit(access); } }; diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 6312de1a723..bd1814ba39b 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -122,6 +122,12 @@ wd_test( args = ["--experimental"], ) +wd_test( + src = "ctx-access-test.wd-test", + args = ["--experimental"], + data = ["ctx-access-test.js"], +) + wd_test( src = "cache-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/ctx-access-test.js b/src/workerd/api/tests/ctx-access-test.js new file mode 100644 index 00000000000..587f5429993 --- /dev/null +++ b/src/workerd/api/tests/ctx-access-test.js @@ -0,0 +1,11 @@ +import { strictEqual } from 'node:assert'; + +export const ctxAccessPropertyExists = { + test(controller, env, ctx) { + // When enable_ctx_access is enabled, the property should exist on ctx + // (as a lazy instance property), even though its value is undefined + // because setAccess() is only called by the host runtime (edgeworker). + strictEqual('access' in ctx, true); + strictEqual(ctx.access, undefined); + }, +}; diff --git a/src/workerd/api/tests/ctx-access-test.wd-test b/src/workerd/api/tests/ctx-access-test.wd-test new file mode 100644 index 00000000000..578e5338703 --- /dev/null +++ b/src/workerd/api/tests/ctx-access-test.wd-test @@ -0,0 +1,14 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "ctx-access-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "ctx-access-test.js") + ], + compatibilityFlags = ["nodejs_compat", "experimental", "enable_ctx_access"], + ) + ), + ], +); diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index d3998c0794b..03477c9da14 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1508,4 +1508,13 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { $compatDisableFlag("resizable_array_buffer_in_blob"); # When enabled, creating a Blob with a resizable ArrayBuffer will throw a TypeError, matching # expected spec behavior. + + enableCtxAccess @174 :Bool + $compatEnableFlag("enable_ctx_access") + $experimental; + # Enables the ctx.access property for Cloudflare Access integration. + # When enabled, ctx.access provides Access authentication context including the + # matched application audience (AUD) and an identity-fetching function. The value + # is set by the host runtime (edgeworker) per-request; in open-source workerd the + # property will always be undefined. } diff --git a/types/defines/access.d.ts b/types/defines/access.d.ts new file mode 100644 index 00000000000..18319ad6269 --- /dev/null +++ b/types/defines/access.d.ts @@ -0,0 +1,57 @@ +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { id: string; type: string }; + /** Geographic information about where the user authenticated. */ + geo?: { country: string }; + /** Group memberships from the identity provider. */ + groups?: Array<{ id: string; name: string; email?: string }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} + +/** + * Cloudflare Access authentication information for the current request. + */ +interface CloudflareAccessContext { + /** + * The audience claim from the Access JWT. This identifies which Access + * application the request matched. + */ + readonly aud: string; + + /** + * Fetches the full identity information for the authenticated user. + * This makes a call to the Access identity service to retrieve extended + * user information such as groups, device posture, and identity provider data. + * + * @returns The subject's identity, if one exists + * @throws May throw if the identity service is unreachable or returns an error. + */ + getIdentity(): Promise; +} diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 0d465249549..d529337a130 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -500,6 +500,7 @@ interface ExecutionContext { readonly key?: string; readonly override?: string; }; + readonly access?: CloudflareAccessContext; abort(reason?: any): void; } type ExportedHandlerFetchHandler< @@ -4683,6 +4684,70 @@ interface EventCounts { ): void; [Symbol.iterator](): IterableIterator; } +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +/** + * Cloudflare Access authentication information for the current request. + */ +interface CloudflareAccessContext { + /** + * The audience claim from the Access JWT. This identifies which Access + * application the request matched. + */ + readonly aud: string; + /** + * Fetches the full identity information for the authenticated user. + * This makes a call to the Access identity service to retrieve extended + * user information such as groups, device posture, and identity provider data. + * + * @returns The subject's identity, if one exists + * @throws May throw if the identity service is unreachable or returns an error. + */ + getIdentity(): Promise; +} // ============ AI Search Error Interfaces ============ interface AiSearchInternalError extends Error {} interface AiSearchNotFoundError extends Error {} diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index f29bd13ac03..f9eaa744744 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -502,6 +502,7 @@ export interface ExecutionContext { readonly key?: string; readonly override?: string; }; + readonly access?: CloudflareAccessContext; abort(reason?: any): void; } export type ExportedHandlerFetchHandler< @@ -4689,6 +4690,70 @@ export interface EventCounts { ): void; [Symbol.iterator](): IterableIterator; } +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +export interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +/** + * Cloudflare Access authentication information for the current request. + */ +export interface CloudflareAccessContext { + /** + * The audience claim from the Access JWT. This identifies which Access + * application the request matched. + */ + readonly aud: string; + /** + * Fetches the full identity information for the authenticated user. + * This makes a call to the Access identity service to retrieve extended + * user information such as groups, device posture, and identity provider data. + * + * @returns The subject's identity, if one exists + * @throws May throw if the identity service is unreachable or returns an error. + */ + getIdentity(): Promise; +} // ============ AI Search Error Interfaces ============ export interface AiSearchInternalError extends Error {} export interface AiSearchNotFoundError extends Error {} diff --git a/types/generated-snapshot/latest/index.d.ts b/types/generated-snapshot/latest/index.d.ts index d7a249b33c8..9dc417dc87d 100755 --- a/types/generated-snapshot/latest/index.d.ts +++ b/types/generated-snapshot/latest/index.d.ts @@ -479,6 +479,7 @@ interface ExecutionContext { passThroughOnException(): void; readonly exports: Cloudflare.Exports; readonly props: Props; + readonly access?: CloudflareAccessContext; } type ExportedHandlerFetchHandler< Env = unknown, @@ -3991,6 +3992,70 @@ declare abstract class Performance { */ toJSON(): object; } +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +/** + * Cloudflare Access authentication information for the current request. + */ +interface CloudflareAccessContext { + /** + * The audience claim from the Access JWT. This identifies which Access + * application the request matched. + */ + readonly aud: string; + /** + * Fetches the full identity information for the authenticated user. + * This makes a call to the Access identity service to retrieve extended + * user information such as groups, device posture, and identity provider data. + * + * @returns The subject's identity, if one exists + * @throws May throw if the identity service is unreachable or returns an error. + */ + getIdentity(): Promise; +} // ============ AI Search Error Interfaces ============ interface AiSearchInternalError extends Error {} interface AiSearchNotFoundError extends Error {} diff --git a/types/generated-snapshot/latest/index.ts b/types/generated-snapshot/latest/index.ts index 343b5d5fd7d..20f01485e39 100755 --- a/types/generated-snapshot/latest/index.ts +++ b/types/generated-snapshot/latest/index.ts @@ -481,6 +481,7 @@ export interface ExecutionContext { passThroughOnException(): void; readonly exports: Cloudflare.Exports; readonly props: Props; + readonly access?: CloudflareAccessContext; } export type ExportedHandlerFetchHandler< Env = unknown, @@ -3997,6 +3998,70 @@ export declare abstract class Performance { */ toJSON(): object; } +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +export interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; +} +/** + * Cloudflare Access authentication information for the current request. + */ +export interface CloudflareAccessContext { + /** + * The audience claim from the Access JWT. This identifies which Access + * application the request matched. + */ + readonly aud: string; + /** + * Fetches the full identity information for the authenticated user. + * This makes a call to the Access identity service to retrieve extended + * user information such as groups, device posture, and identity provider data. + * + * @returns The subject's identity, if one exists + * @throws May throw if the identity service is unreachable or returns an error. + */ + getIdentity(): Promise; +} // ============ AI Search Error Interfaces ============ export interface AiSearchInternalError extends Error {} export interface AiSearchNotFoundError extends Error {}