Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/workerd/api/global-scope.c++
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <workerd/api/tracing.h>
#include <workerd/api/util.h>
#include <workerd/api/worker-rpc.h>
#include <workerd/io/access-info.h>
#include <workerd/io/compatibility-date.h>
#include <workerd/io/features.h>
#include <workerd/io/io-context.h>
Expand Down Expand Up @@ -96,6 +97,31 @@ jsg::Optional<jsg::Ref<Tracing>> ExecutionContext::getTracing(jsg::Lock& js) {
return js.alloc<Tracing>();
}

kj::StringPtr AccessContext::getAud() {
return info->getAudience();
}

jsg::Promise<jsg::Optional<jsg::JsValue>> AccessContext::getIdentity(jsg::Lock& js) {
auto& ioctx = IoContext::current();
return ioctx.awaitIo(js, info->getIdentity(),
[](jsg::Lock& js, kj::Maybe<kj::String> json) -> jsg::Optional<jsg::JsValue> {
KJ_IF_SOME(j, json) {
return jsg::JsValue(js.parseJson(j).getHandle(js));
}
return kj::none;
});
}

jsg::Optional<jsg::Ref<AccessContext>> ExecutionContext::getAccess(jsg::Lock& js) {
// Pull the per-request AccessInfo (if any) off the current IncomingRequest. Standalone workerd
// never supplies one; production embedders construct one before calling newWorkerEntrypoint().
if (!IoContext::hasCurrent()) return kj::none;
auto& ioctx = IoContext::current();
KJ_IF_SOME(info, ioctx.getAccessInfo()) {
return js.alloc<AccessContext>(ioctx.addObject(kj::addRef(info)));
}
return kj::none;
}
void ExecutionContext::abort(jsg::Lock& js, jsg::Optional<jsg::Value> reason) {
KJ_IF_SOME(r, reason) {
IoContext::current().abort(js.exceptionToKj(kj::mv(r)));
Expand Down
46 changes: 45 additions & 1 deletion src/workerd/api/global-scope.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ namespace workerd::jsg {
class DOMException;
} // namespace workerd::jsg

namespace workerd {
class AccessInfo;
} // namespace workerd

namespace workerd::api {

class Tracing;
Expand Down Expand Up @@ -240,6 +244,37 @@ class CacheContext: public jsg::Object {
}
};

// Concrete wrapper exposing per-request Cloudflare Access authentication info to JavaScript
// as `ctx.access`. The actual auth data is supplied by the embedding application via
// `workerd::AccessInfo`, which is plumbed through `newWorkerEntrypoint()` onto
// `IoContext::IncomingRequest`.
//
// Standalone workerd never constructs one of these (no `AccessInfo` is supplied), so
// `ctx.access` is `undefined`. Embedders construct a concrete `AccessInfo` subclass and pass it
// through the entrypoint; `ExecutionContext::getAccess()` lazily wraps it in this class.
class AccessContext: public jsg::Object {
public:
explicit AccessContext(IoOwn<AccessInfo> info): info(kj::mv(info)) {}

// Returns the audience claim from the Access JWT.
kj::StringPtr getAud();

// Fetches the full identity information for the authenticated user. Resolves to `undefined`
// if no identity is associated with the request (e.g. service-token authentication).
jsg::Promise<jsg::Optional<jsg::JsValue>> getIdentity(jsg::Lock& js);

JSG_RESOURCE_TYPE(AccessContext) {
JSG_READONLY_INSTANCE_PROPERTY(aud, getAud);
JSG_METHOD(getIdentity);
JSG_TS_OVERRIDE(CloudflareAccessContext {
readonly aud: string;
getIdentity(): Promise<CloudflareAccessIdentity | undefined>;
});
}

private:
IoOwn<AccessInfo> info;
};
class ExecutionContext: public jsg::Object {
public:
ExecutionContext(jsg::Lock& js, jsg::JsValue exports)
Expand Down Expand Up @@ -295,6 +330,10 @@ class ExecutionContext: public jsg::Object {

jsg::Optional<jsg::Ref<Tracing>> getTracing(jsg::Lock& js);

// Returns an AccessContext for the current request, or empty jsg::Optional otherwise.
// Called by the runtime to provide Cloudflare Access authentication context.
jsg::Optional<jsg::Ref<AccessContext>> getAccess(jsg::Lock& js);

JSG_RESOURCE_TYPE(ExecutionContext, CompatibilityFlags::Reader flags) {
JSG_METHOD(waitUntil);
JSG_METHOD(passThroughOnException);
Expand All @@ -306,6 +345,7 @@ class ExecutionContext: public jsg::Object {
if (flags.getEnableVersionApi()) {
JSG_LAZY_INSTANCE_PROPERTY(version, getVersion);
}
JSG_LAZY_INSTANCE_PROPERTY(access, getAccess);

// ctx.tracing - user tracing API. The *type* is always visible (so the generated
// `Tracing` / `Span` types exist in every compat-date snapshot, not only the
Expand Down Expand Up @@ -340,11 +380,13 @@ class ExecutionContext: public jsg::Object {
readonly key?: string;
readonly override?: string;
};
readonly access?: CloudflareAccessContext;
});
} else {
JSG_TS_OVERRIDE(<Props = unknown> {
readonly props: Props;
readonly exports: Cloudflare.Exports;
readonly access?: CloudflareAccessContext;
});
}
} else {
Expand All @@ -357,10 +399,12 @@ class ExecutionContext: public jsg::Object {
readonly key?: string;
readonly override?: string;
};
readonly access?: CloudflareAccessContext;
});
} else {
JSG_TS_OVERRIDE(<Props = unknown> {
readonly props: Props;
readonly access?: CloudflareAccessContext;
});
}
}
Expand Down Expand Up @@ -1089,6 +1133,6 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope {
api::ExecutionContext, api::ExportedHandler, \
api::ServiceWorkerGlobalScope::StructuredCloneOptions, api::Navigator, \
api::AlarmInvocationInfo, api::Immediate, api::Cloudflare, api::CachePurgeError, \
api::CachePurgeResult, api::CachePurgeOptions, api::CacheContext
api::CachePurgeResult, api::CachePurgeOptions, api::CacheContext, api::AccessContext
// The list of global-scope.h types that are added to worker.c++'s JSG_DECLARE_ISOLATE_TYPE
} // namespace workerd::api
5 changes: 5 additions & 0 deletions src/workerd/api/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ wd_test(
args = ["--experimental"],
)

wd_test(
src = "ctx-access-test.wd-test",
data = ["ctx-access-test.js"],
)

wd_test(
src = "cache-test.wd-test",
args = ["--experimental"],
Expand Down
16 changes: 16 additions & 0 deletions src/workerd/api/tests/ctx-access-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

import { strictEqual } from 'node:assert';

export const ctxAccessPropertyExists = {
test(controller, env, ctx) {
// The access property is always present on ctx as a lazy instance property.
// In standalone workerd no AccessInfo is supplied to newWorkerEntrypoint(), so the
// current IncomingRequest has no AccessInfo and getAccess() returns kj::none, which
// surfaces as `undefined` to JS.
strictEqual('access' in ctx, true);
strictEqual(ctx.access, undefined);
},
};
14 changes: 14 additions & 0 deletions src/workerd/api/tests/ctx-access-test.wd-test
Original file line number Diff line number Diff line change
@@ -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"],
)
),
],
);
1 change: 1 addition & 0 deletions src/workerd/io/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ wd_cc_library(
"worker-fs.c++",
] + ["//src/workerd/api:srcs"],
hdrs = [
"access-info.h",
"compatibility-date.h",
"external-pusher.h",
"hibernation-manager.h",
Expand Down
40 changes: 40 additions & 0 deletions src/workerd/io/access-info.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

#pragma once

#include <kj/async.h>
#include <kj/refcount.h>
#include <kj/string.h>

namespace workerd {

// Per-request Cloudflare Access authentication information.
//
// This is the I/O-side carrier for Access auth data. It is created by the embedding application
// (e.g. the production runtime) before invoking the worker, plumbed through `newWorkerEntrypoint()`
// into the `IoContext::IncomingRequest`, and surfaced to JavaScript by the concrete
// `api::AccessContext` wrapper as `ctx.access`.
//
// In standalone workerd this is never constructed; `ctx.access` evaluates to `undefined`.
//
// This type intentionally lives in `io/` rather than `api/` because:
// - It is the polymorphism boundary between embedders (workerd vs. production), not the
// JS-facing type.
// - It carries per-request data that flows through `newWorkerEntrypoint` → `IncomingRequest`,
// not through `Worker::Api` (which is per-isolate) or `IoChannelFactory`.
class AccessInfo: public kj::Refcounted {
public:
virtual ~AccessInfo() noexcept(false) = default;

// The audience claim from the Access JWT. Stable for the lifetime of the request.
virtual kj::StringPtr getAudience() = 0;

// Fetches the full identity information for the authenticated user, equivalent to calling
// /cdn-cgi/access/get-identity. The returned string is a JSON document; `kj::none` indicates
// no identity is available (e.g. service-token authentication).
virtual kj::Promise<kj::Maybe<kj::String>> getIdentity() = 0;
};

} // namespace workerd
5 changes: 4 additions & 1 deletion src/workerd/io/io-context.c++
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include "io-context.h"

#include <workerd/io/access-info.h>
#include <workerd/io/io-gate.h>
#include <workerd/io/tracer.h>
#include <workerd/io/worker.h>
Expand Down Expand Up @@ -218,11 +219,13 @@ IoContext::IncomingRequest::IoContext_IncomingRequest(kj::Own<IoContext> context
kj::Own<IoChannelFactory> ioChannelFactoryParam,
kj::Own<RequestObserver> metricsParam,
kj::Maybe<kj::Own<BaseTracer>> workerTracer,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan)
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
kj::Maybe<kj::Own<AccessInfo>> accessInfo)
: context(kj::mv(contextParam)),
metrics(kj::mv(metricsParam)),
workerTracer(kj::mv(workerTracer)),
ioChannelFactory(kj::mv(ioChannelFactoryParam)),
accessInfo(kj::mv(accessInfo)),
maybeTriggerInvocationSpan(kj::mv(maybeTriggerInvocationSpan)) {}

tracing::InvocationSpanContext& IoContext::IncomingRequest::getInvocationSpanContext() {
Expand Down
18 changes: 17 additions & 1 deletion src/workerd/io/io-context.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "worker.h"

#include <workerd/api/deferred-proxy.h>
#include <workerd/io/access-info.h>
#include <workerd/io/actor-id.h>
#include <workerd/io/external-pusher.h>
#include <workerd/io/io-channels.h>
Expand Down Expand Up @@ -71,7 +72,8 @@ class IoContext_IncomingRequest final {
kj::Own<IoChannelFactory> ioChannelFactory,
kj::Own<RequestObserver> metrics,
kj::Maybe<kj::Own<BaseTracer>> workerTracer,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan);
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
kj::Maybe<kj::Own<AccessInfo>> accessInfo = kj::none);
KJ_DISALLOW_COPY_AND_MOVE(IoContext_IncomingRequest);
~IoContext_IncomingRequest() noexcept(false);

Expand Down Expand Up @@ -131,6 +133,12 @@ class IoContext_IncomingRequest final {
return rootUserTraceSpan.addRef();
}

// The Cloudflare Access auth info for this request, if any was provided by the embedder. Used
// to populate `ctx.access` in JavaScript.
kj::Maybe<AccessInfo&> getAccessInfo() {
return accessInfo.map([](kj::Own<AccessInfo>& p) -> AccessInfo& { return *p; });
}

// The invocation span context is a unique identifier for a specific
// worker invocation.
tracing::InvocationSpanContext& getInvocationSpanContext();
Expand All @@ -140,6 +148,7 @@ class IoContext_IncomingRequest final {
kj::Own<RequestObserver> metrics;
kj::Maybe<kj::Own<BaseTracer>> workerTracer;
kj::Own<IoChannelFactory> ioChannelFactory;
kj::Maybe<kj::Own<AccessInfo>> accessInfo;

// Root user trace span for this request. Populated during delivered() via
// BaseTracer::makeUserRequestSpan(); otherwise a null SpanParent. The tracer it references
Expand Down Expand Up @@ -240,6 +249,13 @@ class IoContext final: public kj::Refcounted, private kj::TaskSet::ErrorHandler
return getCurrentIncomingRequest().getRootUserTraceSpan();
}

// The Cloudflare Access auth info for the current incoming request, if any was provided by
// the embedder. Used to populate `ctx.access` in JavaScript.
kj::Maybe<AccessInfo&> getAccessInfo() {
if (incomingRequests.empty()) return kj::none;
return getCurrentIncomingRequest().getAccessInfo();
}

LimitEnforcer& getLimitEnforcer() {
return *limitEnforcer;
}
Expand Down
22 changes: 14 additions & 8 deletions src/workerd/io/worker-entrypoint.c++
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <workerd/api/basics.h>
#include <workerd/api/global-scope.h>
#include <workerd/api/util.h>
#include <workerd/io/access-info.h>
#include <workerd/io/features.h>
#include <workerd/io/io-context.h>
#include <workerd/io/limit-enforcer.h>
Expand Down Expand Up @@ -61,7 +62,8 @@ class WorkerEntrypoint final: public WorkerInterface {
kj::Maybe<kj::String> cfBlobJson,
kj::Maybe<Worker::VersionInfo> versionInfo,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
bool isDynamicDispatch);
bool isDynamicDispatch,
kj::Maybe<kj::Own<AccessInfo>> accessInfo);

kj::Promise<void> request(kj::HttpMethod method,
kj::StringPtr url,
Expand Down Expand Up @@ -110,7 +112,8 @@ class WorkerEntrypoint final: public WorkerInterface {
kj::Own<IoChannelFactory> ioChannelFactory,
kj::Own<RequestObserver> metrics,
kj::Maybe<kj::Own<BaseTracer>> workerTracer,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan);
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
kj::Maybe<kj::Own<AccessInfo>> accessInfo);

template <typename T>
kj::Promise<T> maybeAddGcPassForTest(IoContext& context, kj::Promise<T> promise);
Expand Down Expand Up @@ -184,15 +187,16 @@ kj::Own<WorkerInterface> WorkerEntrypoint::construct(ThreadContext& threadContex
kj::Maybe<kj::String> cfBlobJson,
kj::Maybe<Worker::VersionInfo> versionInfo,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
bool isDynamicDispatch) {
bool isDynamicDispatch,
kj::Maybe<kj::Own<AccessInfo>> accessInfo) {
TRACE_EVENT("workerd", "WorkerEntrypoint::construct()");

auto obj = kj::heap<WorkerEntrypoint>(kj::Badge<WorkerEntrypoint>(), threadContext,
waitUntilTasks, tunnelExceptions, isDynamicDispatch, entrypointName, kj::mv(props),
kj::mv(cfBlobJson), kj::mv(versionInfo));
obj->init(kj::mv(worker), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency),
kj::mv(ioChannelFactory), kj::addRef(*metrics), kj::mv(workerTracer),
kj::mv(maybeTriggerInvocationSpan));
kj::mv(maybeTriggerInvocationSpan), kj::mv(accessInfo));
auto& wrapper = metrics->wrapWorkerInterface(*obj);
return kj::attachRef(wrapper, kj::mv(obj), kj::mv(metrics));
}
Expand Down Expand Up @@ -222,7 +226,8 @@ void WorkerEntrypoint::init(kj::Own<const Worker> worker,
kj::Own<IoChannelFactory> ioChannelFactory,
kj::Own<RequestObserver> metrics,
kj::Maybe<kj::Own<BaseTracer>> workerTracer,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan) {
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
kj::Maybe<kj::Own<AccessInfo>> accessInfo) {
TRACE_EVENT("workerd", "WorkerEntrypoint::init()");
// We need to construct the IoContext -- unless this is an actor and it already has a
// IoContext, in which case we reuse it.
Expand Down Expand Up @@ -252,7 +257,7 @@ void WorkerEntrypoint::init(kj::Own<const Worker> worker,
}

incomingRequest = kj::heap<IoContext::IncomingRequest>(kj::mv(context), kj::mv(ioChannelFactory),
kj::mv(metrics), kj::mv(workerTracer), kj::mv(maybeTriggerInvocationSpan))
kj::mv(metrics), kj::mv(workerTracer), kj::mv(maybeTriggerInvocationSpan), kj::mv(accessInfo))
.attach(kj::mv(actor));
}

Expand Down Expand Up @@ -1008,12 +1013,13 @@ kj::Own<WorkerInterface> newWorkerEntrypoint(ThreadContext& threadContext,
kj::Maybe<kj::String> cfBlobJson,
kj::Maybe<Worker::VersionInfo> versionInfo,
kj::Maybe<tracing::InvocationSpanContext> maybeTriggerInvocationSpan,
bool isDynamicDispatch) {
bool isDynamicDispatch,
kj::Maybe<kj::Own<AccessInfo>> accessInfo) {
return WorkerEntrypoint::construct(threadContext, kj::mv(worker), kj::mv(entrypointName),
kj::mv(props), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency),
kj::mv(ioChannelFactory), kj::mv(metrics), waitUntilTasks, tunnelExceptions,
kj::mv(workerTracer), kj::mv(cfBlobJson), kj::mv(versionInfo),
kj::mv(maybeTriggerInvocationSpan), isDynamicDispatch);
kj::mv(maybeTriggerInvocationSpan), isDynamicDispatch, kj::mv(accessInfo));
}

} // namespace workerd
Loading
Loading