πŸ“¦ threepointone / cloudflared-login-worker

β˜… 3 stars β‘‚ 0 forks πŸ‘ 3 watching
πŸ“₯ Clone https://github.com/threepointone/cloudflared-login-worker.git
HTTPS git clone https://github.com/threepointone/cloudflared-login-worker.git
SSH git clone git@github.com:threepointone/cloudflared-login-worker.git
CLI gh repo clone threepointone/cloudflared-login-worker
Sunil Pai Sunil Pai Refactor to browser-only keypair and client-side decryption 95ed5aa 1 months ago πŸ“ History
πŸ“‚ main View all commits β†’
πŸ“ public
πŸ“ src
πŸ“„ .gitignore
πŸ“„ index.html
πŸ“„ package.json
πŸ“„ README.md
πŸ“„ tsconfig.json
πŸ“„ vite.config.ts
πŸ“„ wrangler.jsonc
πŸ“„ README.md

Cloudflared Login for Workers

A Cloudflare Worker implementation of the cloudflared access login flow, allowing web applications to authenticate users via Cloudflare Access.

What This Does

This project replicates the authentication flow used by cloudflared (the Cloudflare Tunnel client) but runs entirely in a Cloudflare Worker + browser. It allows your web application to:

  • Authenticate users against Cloudflare Access-protected applications
  • Retrieve JWT tokens (both app token and org token) after authentication
  • Use those tokens for subsequent API calls to Access-protected resources

How It Works

The authentication flow uses Cloudflare's token transfer service with end-to-end encryption. The private key never leaves the browser.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Browser β”‚     β”‚ Worker  β”‚     β”‚ Access Login β”‚     β”‚ Transfer Serviceβ”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 1. Generate keypair locally      β”‚                      β”‚
     β”‚    (private key stays in browser)β”‚                      β”‚
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 2. POST /api/login               β”‚                      β”‚
     β”‚    {appUrl, publicKey}           β”‚                      β”‚
     │──────────────>β”‚                  β”‚                      β”‚
     β”‚               β”‚ 3. Get app info, β”‚                      β”‚
     β”‚               β”‚    build login URL                      β”‚
     β”‚    {loginUrl} β”‚                  β”‚                      β”‚
     β”‚<──────────────│                  β”‚                      β”‚
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 4. Open popup, user logs in      β”‚                      β”‚
     │──────────────────────────────────>                      β”‚
     β”‚               β”‚                  β”‚                      β”‚
     β”‚               β”‚                  β”‚ 5. Store encrypted   β”‚
     β”‚               β”‚                  β”‚    tokens            β”‚
     β”‚               β”‚                  │─────────────────────>β”‚
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 6. Popup closes (auto)           β”‚                      β”‚
     β”‚<─────────────────────────────────│                      β”‚
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 7. GET /api/transfer?publicKey=..β”‚                      β”‚
     │──────────────>β”‚                  β”‚                      β”‚
     β”‚               β”‚ 8. Proxy request β”‚                      β”‚
     β”‚               │─────────────────────────────────────────>
     β”‚  {encrypted}  β”‚    {encrypted}   β”‚                      β”‚
     β”‚<──────────────│<─────────────────────────────────────────
     β”‚               β”‚                  β”‚                      β”‚
     β”‚ 9. Decrypt locally with          β”‚                      β”‚
     β”‚    private key (in browser)      β”‚                      β”‚

Encryption

The token transfer uses NaCl Box encryption (Curve25519 + XSalsa20-Poly1305):

  • Browser generates a keypair, sends public key to Worker
  • Worker includes public key in the login URL
  • Transfer service encrypts tokens with the public key
  • Browser decrypts with private key (never sent over network)
This ensures tokens are encrypted end-to-end and the private key never leaves the browser.

Project Structure

β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ login.ts      # Server-side helpers (app info, login URL, transfer proxy)
β”‚   β”œβ”€β”€ server.ts     # Worker API endpoints
β”‚   β”œβ”€β”€ app.tsx       # React frontend (keypair generation, decryption)
β”‚   └── client.tsx    # React entry point
β”œβ”€β”€ wrangler.jsonc    # Worker configuration
└── package.json

API Endpoints

POST /api/login

Get app info and build login URL. The browser provides the public key.

Request:

{
  "appUrl": "https://your-access-protected-app.example.com",
  "publicKey": "base64url-encoded-public-key",
  "autoClose": true
}

Response:

{
  "loginUrl": "https://your-app.example.com/cdn-cgi/access/cli?token=...",
  "appInfo": {
    "authDomain": "your-org.cloudflareaccess.com",
    "appAUD": "4dfeff11338191c2342efc3918871896...",
    "appDomain": "your-app.example.com"
  }
}

GET /api/transfer?publicKey=<key>&fedramp=<bool>

Proxy to the transfer service (CORS workaround). Returns encrypted data for the browser to decrypt.

Response (202 - Pending):

{
  "status": "pending",
  "message": "Token not ready yet"
}

Response (200 - Success):

{
  "encryptedData": "base64-encoded-encrypted-data",
  "servicePublicKey": "base64url-encoded-service-public-key"
}

GET /api/app-info?appUrl=<url>

Get information about an Access-protected application.

Response:

{
  "authDomain": "your-org.cloudflareaccess.com",
  "appAUD": "4dfeff11338191c2342efc3918871896...",
  "appDomain": "your-app.example.com"
}

Setup

1. Install Dependencies

npm install

2. Development

npm start

3. Deploy

npm run deploy

Usage in Your Application

import nacl from "tweetnacl";

// Helper functions
function encodeBase64url(bytes: Uint8Array): string {
  const base64 = btoa(String.fromCharCode(...bytes));
  return base64.replace(/\+/g, "-").replace(/\//g, "_");
}

function decodeBase64(str: string): Uint8Array {
  const binary = atob(str);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function decodeBase64url(str: string): Uint8Array {
  let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
  while (base64.length % 4) base64 += "=";
  return decodeBase64(base64);
}

// 1. Generate keypair locally
const keyPair = nacl.box.keyPair();
const publicKey = encodeBase64url(keyPair.publicKey);

// 2. Get login URL from server
const response = await fetch("/api/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    appUrl: "https://protected-app.example.com",
    publicKey,
  }),
});
const { loginUrl } = await response.json();

// 3. Open login in popup
const popup = window.open(loginUrl, "login", "width=500,height=600");

// 4. Poll for encrypted tokens
const pollForTokens = async (): Promise<{ app_token: string; org_token: string }> => {
  const res = await fetch(`/api/transfer?publicKey=${encodeURIComponent(publicKey)}`);

  if (res.status === 200) {
    const { encryptedData, servicePublicKey } = await res.json();

    // 5. Decrypt locally
    const data = decodeBase64(encryptedData);
    const nonce = data.slice(0, 24);
    const ciphertext = data.slice(24);
    const svcPubKey = decodeBase64url(servicePublicKey);

    const decrypted = nacl.box.open(ciphertext, nonce, svcPubKey, keyPair.secretKey);
    if (!decrypted) throw new Error("Decryption failed");

    return JSON.parse(new TextDecoder().decode(decrypted));
  }

  if (res.status === 202) {
    await new Promise((r) => setTimeout(r, 1000));
    return pollForTokens();
  }

  throw new Error("Login failed");
};

const tokens = await pollForTokens();
console.log("App Token:", tokens.app_token);
console.log("Org Token:", tokens.org_token);

Tokens

  • App Token: JWT scoped to the specific Access application. Use this in the cf-access-token header for requests to that application.
  • Org Token: JWT scoped to the entire Access organization. Can be exchanged for app tokens to other applications in the same org.

Dependencies

  • tweetnacl - NaCl cryptography library for encryption/decryption (runs in browser)

References