-
Notifications
You must be signed in to change notification settings - Fork 13.5k
[Access] Fix OpenCode casing and use long-form --header flag #29711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,394 @@ | ||||||
| --- | ||||||
| pcx_content_type: how-to | ||||||
| title: Authenticate coding agents | ||||||
| description: Grant coding agents like Claude Code, OpenCode, and Windsurf access to resources protected by Cloudflare Access using cloudflared or service tokens. | ||||||
| products: | ||||||
| - access | ||||||
| tags: | ||||||
| - AI | ||||||
| - Authentication | ||||||
| sidebar: | ||||||
| order: 7 | ||||||
| --- | ||||||
|
|
||||||
| Coding agents such as Claude Code, OpenCode, and Windsurf often need to reach resources protected by [Cloudflare Access](/cloudflare-one/access-controls/). When a resource is behind Access, unauthenticated requests receive a redirect or `403` error instead of the expected response. Your agent needs a way to authenticate before it can reach the resource. | ||||||
|
|
||||||
| This page covers two authentication methods: | ||||||
|
|
||||||
| - [**cloudflared**](#use-cloudflared) — authenticates under your user identity. Use for interactive development where you can complete a browser login. | ||||||
| - [**Service tokens**](#use-service-tokens) — authenticates with a static credential pair. Use for headless or automated workflows where no browser is available. | ||||||
|
|
||||||
| :::note | ||||||
| Cloudflare Access also supports Managed OAuth for protected resources, which you can use to grant authorization to coding agents. | ||||||
| ::: | ||||||
|
|
||||||
| ## Use cloudflared | ||||||
|
|
||||||
| With `cloudflared`, your agent authenticates under your user identity. On first use, `cloudflared` opens a browser window for an interactive login. After that, the session persists for the [session duration](/cloudflare-one/access-controls/access-settings/session-management/) configured for the application. After the session expires, the next request requires a new browser login. | ||||||
|
|
||||||
| ### Prerequisites | ||||||
|
|
||||||
| [Download and install cloudflared](/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/). | ||||||
|
|
||||||
| ### Make requests with cloudflared access curl | ||||||
|
|
||||||
| For direct requests to a protected resource, use `cloudflared access curl`. This handles authentication automatically and does not require token management. | ||||||
|
|
||||||
| ```sh | ||||||
| cloudflared access curl https://example.com/api/endpoint | ||||||
| ``` | ||||||
|
|
||||||
| If this is the first request in a session, `cloudflared` opens a browser for the user to authenticate. Prompt the user to complete the login if needed. | ||||||
|
|
||||||
| ### Use a reusable token | ||||||
|
|
||||||
| Some agents make HTTP requests using their own client libraries instead of calling `cloudflared` directly. In this case, log in to get a token and pass it as a header: | ||||||
|
|
||||||
| ```sh | ||||||
| CF_TOKEN=$(cloudflared access login https://example.com) | ||||||
| curl --header "cf-access-token: $CF_TOKEN" https://example.com/api/endpoint | ||||||
| ``` | ||||||
|
|
||||||
| The token is valid for the session duration configured for the application. | ||||||
|
|
||||||
| For more information, refer to [Connect through Access using a CLI](/cloudflare-one/tutorials/cli/) and [Client-side cloudflared](/cloudflare-one/access-controls/applications/non-http/cloudflared-authentication/). | ||||||
|
|
||||||
| ## Use service tokens | ||||||
|
|
||||||
| Service tokens are static credential pairs that authenticate requests without a browser login. Use them for automated workflows where no user is present. | ||||||
|
|
||||||
| 1. [Create a service token](/cloudflare-one/access-controls/service-credentials/service-tokens/#create-a-service-token) and save the **Client ID** and **Client Secret**. | ||||||
|
|
||||||
| 2. In the Access application's policy configuration, add a [Service Auth policy](/cloudflare-one/access-controls/policies/#service-auth). This policy type accepts service token credentials instead of requiring an identity provider login. Use the **Service Token** selector and select the token you created. | ||||||
|
|
||||||
| | Action | Rule type | Selector | Value | | ||||||
| | ------------ | --------- | ------------- | ---------------- | | ||||||
| | Service Auth | Include | Service Token | Your agent token | | ||||||
|
|
||||||
| 3. Store the Client ID and Client Secret in a secure location on your machine that your agent can read. | ||||||
|
|
||||||
| 4. Include both values as headers in requests to the protected resource: | ||||||
|
|
||||||
| ```sh | ||||||
| curl --header "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ | ||||||
| --header "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ | ||||||
| https://example.com/api/endpoint | ||||||
| ``` | ||||||
|
|
||||||
| For more information, refer to [Service tokens](/cloudflare-one/access-controls/service-credentials/service-tokens/). | ||||||
|
|
||||||
| ## Configure your agent | ||||||
|
|
||||||
| Add an `AGENTS.md` file to your project root with the following skill definition. This instructs coding agents to automatically detect Cloudflare Access-protected resources and authenticate using the standard OAuth 2.0 flow with PKCE (RFC 9728). | ||||||
|
|
||||||
| ````markdown | ||||||
| --- | ||||||
| name: access-oauth | ||||||
| description: "Detect Cloudflare Access-protected websites and authenticate via the standard OAuth 2.0 flow (RFC 9728 resource metadata, dynamic client registration, authorization code + PKCE)" | ||||||
| license: MIT | ||||||
| compatibility: opencode | ||||||
| metadata: | ||||||
| category: authentication | ||||||
| audience: developers | ||||||
| --- | ||||||
|
|
||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Closing code fencing |
||||||
| # Access OAuth Authentication | ||||||
|
|
||||||
| Authenticate to Cloudflare Access-protected resources using standard OAuth 2.0 | ||||||
| (resource metadata discovery, dynamic client registration, authorization code with PKCE). | ||||||
|
|
||||||
| ## When to Use | ||||||
|
|
||||||
| Use this skill when: | ||||||
|
|
||||||
| - You need to access a URL that returns HTTP 401 | ||||||
| - The response contains a `www-authenticate: Bearer` header with a `resource_metadata` URL | ||||||
| - The resource metadata indicates it is a Cloudflare Access-protected resource | ||||||
| - You want to authenticate interactively through the user's IdP | ||||||
|
|
||||||
| ## Step 1: Detect a Protected Resource | ||||||
|
|
||||||
| Make a request and inspect the response headers: | ||||||
|
|
||||||
| ```bash | ||||||
| curl -sI -L <URL> 2>&1 | ||||||
| ``` | ||||||
|
|
||||||
| Look for a **401** response with a `www-authenticate` header like: | ||||||
|
|
||||||
| ``` | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Should this be text? |
||||||
| www-authenticate: Bearer realm="OAuth", error="invalid_token", | ||||||
| error_description="Missing or invalid access token", | ||||||
| resource_metadata="https://<hostname>/.well-known/cloudflare-access-protected-resource/" | ||||||
| ``` | ||||||
|
|
||||||
| If you see this header, the site supports the OAuth flow. Proceed to Step 2. | ||||||
|
|
||||||
| The JSON body of the 401 will also contain: | ||||||
|
|
||||||
| ```json | ||||||
| { | ||||||
| "error": "invalid_token", | ||||||
| "error_description": "Missing or invalid access token", | ||||||
| "resource_metadata": "https://<hostname>/.well-known/cloudflare-access-protected-resource/" | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| ### If No `www-authenticate` Header | ||||||
|
|
||||||
| If the 401 does not include `www-authenticate` with `resource_metadata`, the site may | ||||||
| not support this OAuth flow. Fall back to `cloudflared access curl` or browser-based | ||||||
| authentication. | ||||||
|
|
||||||
| ## Step 2: Fetch Resource Metadata | ||||||
|
|
||||||
| Fetch the resource metadata URL from the `www-authenticate` header: | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s https://<hostname>/.well-known/cloudflare-access-protected-resource/ | ||||||
| ``` | ||||||
|
|
||||||
| Expected response: | ||||||
|
|
||||||
| ```json | ||||||
| { | ||||||
| "resource": "https://<hostname>", | ||||||
| "protected": true, | ||||||
| "team_domain": "<team>.cloudflareaccess.com", | ||||||
| "authorization_servers": ["https://<team>.cloudflareaccess.com"], | ||||||
| "authentication_method": "cloudflared", | ||||||
| "authentication_method_description": "Use `cloudflared access curl`...", | ||||||
| "authentication_method_documentation": "https://developers.cloudflare.com/cloudflare-one/tutorials/cli/" | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Extract the **authorization server** URL from `authorization_servers[0]` (e.g. `https://<team>.cloudflareaccess.com`). | ||||||
|
|
||||||
| ## Step 3: Fetch OAuth Authorization Server Metadata | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s https://<team>.cloudflareaccess.com/.well-known/oauth-authorization-server | ||||||
| ``` | ||||||
|
|
||||||
| Expected response: | ||||||
|
|
||||||
| ```json | ||||||
| { | ||||||
| "issuer": "<team>.cloudflareaccess.com", | ||||||
| "authorization_endpoint": "https://<team>.cloudflareaccess.com/cdn-cgi/access/oauth/authorization", | ||||||
| "token_endpoint": "https://<team>.cloudflareaccess.com/cdn-cgi/access/oauth/token", | ||||||
| "response_types_supported": ["code"], | ||||||
| "response_modes_supported": ["query"], | ||||||
| "grant_types_supported": ["authorization_code", "refresh_token"], | ||||||
| "token_endpoint_auth_methods_supported": [ | ||||||
| "client_secret_basic", | ||||||
| "client_secret_post", | ||||||
| "none" | ||||||
| ], | ||||||
| "revocation_endpoint": "https://<team>.cloudflareaccess.com/cdn-cgi/access/oauth/revoke", | ||||||
| "registration_endpoint": "https://<team>.cloudflareaccess.com/cdn-cgi/access/oauth/registration", | ||||||
| "code_challenge_methods_supported": ["S256"] | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Verify that: | ||||||
|
|
||||||
| - `"none"` is in `token_endpoint_auth_methods_supported` (allows public clients) | ||||||
| - `"authorization_code"` is in `grant_types_supported` | ||||||
| - `"S256"` is in `code_challenge_methods_supported` | ||||||
| - A `registration_endpoint` is present | ||||||
|
|
||||||
| Extract the **registration_endpoint**, **authorization_endpoint**, and **token_endpoint**. | ||||||
|
|
||||||
| ## Step 4: Dynamic Client Registration | ||||||
|
|
||||||
| Register a public OAuth client: | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s -X POST <registration_endpoint> \ | ||||||
| -H "Content-Type: application/json" \ | ||||||
| -d '{ | ||||||
| "redirect_uris": ["http://localhost:8400/callback"], | ||||||
| "token_endpoint_auth_method": "none", | ||||||
| "grant_types": ["authorization_code"], | ||||||
| "response_types": ["code"], | ||||||
| "resource": "https://<hostname>" | ||||||
| }' | ||||||
| ``` | ||||||
|
|
||||||
| Expected response: | ||||||
|
|
||||||
| ```json | ||||||
| { | ||||||
| "client_id": "<uuid>", | ||||||
| "redirect_uris": ["http://localhost:8400/callback"], | ||||||
| "grant_types": ["authorization_code"], | ||||||
| "response_types": ["code"], | ||||||
| "token_endpoint_auth_method": "none", | ||||||
| "registration_client_uri": "...", | ||||||
| "client_id_issued_at": 1234567890 | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Save the **client_id**. | ||||||
|
|
||||||
| ## Step 5: Generate PKCE Challenge | ||||||
|
|
||||||
| Generate a code verifier and S256 challenge. Ensure the challenge starts with an | ||||||
| alphanumeric character to avoid URL parsing issues: | ||||||
|
|
||||||
| ```bash | ||||||
| while true; do | ||||||
| CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-') | ||||||
| CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '/+' '_-') | ||||||
| if [[ "$CODE_CHALLENGE" =~ ^[a-zA-Z0-9] ]]; then | ||||||
| break | ||||||
| fi | ||||||
| done | ||||||
| ``` | ||||||
|
|
||||||
| **Important**: The code challenge MUST start with `[a-zA-Z0-9]`. A leading `-` or `_` | ||||||
| can cause URL parameter parsing failures on the authorization server. | ||||||
|
|
||||||
| ## Step 6: Authorization Code Flow with Local Callback | ||||||
|
|
||||||
| Start a local HTTP server to catch the callback, then direct the user to the | ||||||
| authorization URL. | ||||||
|
|
||||||
| ### Build the Authorization URL | ||||||
|
|
||||||
| ``` | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Is txt correct here? |
||||||
| <authorization_endpoint>? | ||||||
| client_id=<client_id>& | ||||||
| redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback& | ||||||
| response_type=code& | ||||||
| code_challenge=<CODE_CHALLENGE>& | ||||||
| code_challenge_method=S256& | ||||||
| resource=<URL-encoded target resource> | ||||||
| ``` | ||||||
|
|
||||||
| ### Start the Callback Listener and Prompt the User | ||||||
|
|
||||||
| Run a Python HTTP server on port 8400 that captures the authorization code: | ||||||
|
|
||||||
| ```python | ||||||
| python3 -c ' | ||||||
| import http.server, urllib.parse | ||||||
|
|
||||||
| class Handler(http.server.BaseHTTPRequestHandler): | ||||||
| def do_GET(self): | ||||||
| parsed = urllib.parse.urlparse(self.path) | ||||||
| params = urllib.parse.parse_qs(parsed.query) | ||||||
| if "code" in params: | ||||||
| code = params["code"][0] | ||||||
| with open("/tmp/oauth_code.txt", "w") as f: | ||||||
| f.write(code) | ||||||
| self.send_response(200) | ||||||
| self.send_header("Content-Type", "text/html") | ||||||
| self.end_headers() | ||||||
| self.wfile.write(b"<h1>Got it!</h1><p>Authorization code received. You can close this tab.</p>") | ||||||
| print(f"CODE={code}", flush=True) | ||||||
| elif "error" in params: | ||||||
| err = params.get("error", [""])[0] | ||||||
| desc = params.get("error_description", [""])[0] | ||||||
| self.send_response(200) | ||||||
| self.send_header("Content-Type", "text/html") | ||||||
| self.end_headers() | ||||||
| self.wfile.write(f"<h1>Error</h1><p>{err}: {desc}</p>".encode()) | ||||||
| print(f"ERROR: {err} - {desc}", flush=True) | ||||||
| else: | ||||||
| self.send_response(400) | ||||||
| self.end_headers() | ||||||
| self.wfile.write(b"Unexpected request") | ||||||
| print(f"Unexpected: {self.path}", flush=True) | ||||||
| import threading | ||||||
| threading.Thread(target=self.server.shutdown).start() | ||||||
| def log_message(self, format, *args): | ||||||
| pass | ||||||
|
|
||||||
| print("Listening on http://localhost:8400 ...", flush=True) | ||||||
| print("Open the authorization URL in your browser.", flush=True) | ||||||
| http.server.HTTPServer(("", 8400), Handler).serve_forever() | ||||||
| ' | ||||||
| ``` | ||||||
|
|
||||||
| **Important**: Use a timeout of at least 120000ms for this bash command since the user | ||||||
| needs time to authenticate in the browser. | ||||||
|
|
||||||
| Tell the user to open the authorization URL in their browser. After they authenticate | ||||||
| with their IdP, the browser will redirect to `http://localhost:8400/callback?code=<code>`, | ||||||
| the server will capture it and shut down. | ||||||
|
|
||||||
| ## Step 7: Exchange Code for Token | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s -X POST <token_endpoint> \ | ||||||
| -H "Content-Type: application/x-www-form-urlencoded" \ | ||||||
| -d "grant_type=authorization_code" \ | ||||||
| -d "code=<AUTH_CODE>" \ | ||||||
| -d "client_id=<CLIENT_ID>" \ | ||||||
| -d "redirect_uri=http://localhost:8400/callback" \ | ||||||
| -d "code_verifier=<CODE_VERIFIER>" | ||||||
| ``` | ||||||
|
|
||||||
| Expected response: | ||||||
|
|
||||||
| ```json | ||||||
| { | ||||||
| "access_token": "oauth:<token>", | ||||||
| "token_type": "bearer", | ||||||
| "expires_in": 900, | ||||||
| "scope": "", | ||||||
| "resource": "https://<hostname>/", | ||||||
| "refresh_token": "oauth:<refresh_token>" | ||||||
| } | ||||||
| ``` | ||||||
|
|
||||||
| Save the **access_token** and **refresh_token**. | ||||||
|
|
||||||
| ## Step 8: Access the Protected Resource | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s https://<hostname>/ \ | ||||||
| -H "Authorization: Bearer <access_token>" | ||||||
| ``` | ||||||
|
|
||||||
| This should now return the actual content behind Cloudflare Access. | ||||||
|
|
||||||
| ## Step 9: Refresh the Token (if needed) | ||||||
|
|
||||||
| If the access token expires (default 900 seconds), use the refresh token: | ||||||
|
|
||||||
| ```bash | ||||||
| curl -s -X POST <token_endpoint> \ | ||||||
| -H "Content-Type: application/x-www-form-urlencoded" \ | ||||||
| -d "grant_type=refresh_token" \ | ||||||
| -d "refresh_token=<REFRESH_TOKEN>" \ | ||||||
| -d "client_id=<CLIENT_ID>" | ||||||
| ``` | ||||||
|
|
||||||
| ## Quick Reference: Full Flow Summary | ||||||
|
|
||||||
| ``` | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Same as above |
||||||
| 1. curl -sI <URL> # Detect 401 + www-authenticate header | ||||||
| 2. curl -s <resource_metadata_url> # Get authorization server | ||||||
| 3. curl -s <as>/.well-known/oauth-authorization-server # Get endpoints | ||||||
| 4. POST <registration_endpoint> # Register public client | ||||||
| 5. Generate PKCE code_verifier + challenge # S256, alphanumeric start | ||||||
| 6. Start localhost:8400 listener # Catch callback | ||||||
| 7. User opens authorization URL # Browser-based IdP auth | ||||||
| 8. POST <token_endpoint> # Exchange code for token | ||||||
| 9. curl -H "Authorization: Bearer <token>" # Access resource | ||||||
| ``` | ||||||
|
|
||||||
| ## Troubleshooting | ||||||
|
|
||||||
| | Problem | Cause | Fix | | ||||||
| | ------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | | ||||||
| | `code_challenge_method must be S256 for public clients` | Code challenge starts with `-` or `_`, corrupting the URL parameter | Regenerate until challenge starts with `[a-zA-Z0-9]` | | ||||||
| | `invalid_grant` on token exchange | Code expired or verifier mismatch | Redo the auth flow; codes are single-use and short-lived | | ||||||
| | 401 after using token | Token expired (default 15 min) | Use refresh token to get a new access token | | ||||||
| | No `www-authenticate` header | Site doesn't support OAuth resource metadata | Fall back to `cloudflared access curl` or browser auth | | ||||||
| | No `registration_endpoint` in AS metadata | Dynamic registration not enabled | Must use a pre-registered client or different auth method | | ||||||
| | Port 8400 already in use | Previous listener didn't shut down | Kill the process or use a different port (update redirect_uri accordingly) | | ||||||
| ```` | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.