diff --git a/app/src/main/kotlin/com/github/gotify/CfAccessSettings.kt b/app/src/main/kotlin/com/github/gotify/CfAccessSettings.kt new file mode 100644 index 00000000..d0a8dd8c --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/CfAccessSettings.kt @@ -0,0 +1,7 @@ +package com.github.gotify + +internal data class CfAccessSettings( + val enabled: Boolean, + val clientId: String, + val clientSecret: String +) diff --git a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt index 53e2c1ce..7b1499ab 100644 --- a/app/src/main/kotlin/com/github/gotify/CoilInstance.kt +++ b/app/src/main/kotlin/com/github/gotify/CoilInstance.kt @@ -22,6 +22,7 @@ import coil.request.ImageRequest import coil.request.Options import coil.request.SuccessResult import com.github.gotify.api.CertUtils +import com.github.gotify.api.CloudflareAccessInterceptor import com.github.gotify.client.model.Application import java.io.IOException import okhttp3.Credentials @@ -31,7 +32,7 @@ import okhttp3.Response import org.tinylog.kotlin.Logger object CoilInstance { - private var holder: Pair? = null + private var holder: Triple? = null @Throws(IOException::class) fun getImageFromUrl( @@ -78,22 +79,34 @@ object CoilInstance { @Synchronized fun get(context: Context): ImageLoader { - val newSettings = Settings(context).sslSettings() + val settings = Settings(context) + val newSslSettings = settings.sslSettings() + val newCfSettings = settings.cfAccessSettings() val copy = holder - if (copy != null && copy.first == newSettings) { - return copy.second + if (copy != null && copy.first == newSslSettings && copy.second == newCfSettings) { + return copy.third } - return makeImageLoader(context, newSettings).also { holder = it }.second + return makeImageLoader(context, newSslSettings, newCfSettings) + .also { holder = it }.third } private fun makeImageLoader( context: Context, - sslSettings: SSLSettings - ): Pair { + sslSettings: SSLSettings, + cfAccessSettings: CfAccessSettings + ): Triple { val builder = OkHttpClient .Builder() .addInterceptor(BasicAuthInterceptor()) CertUtils.applySslSettings(builder, sslSettings) + if (cfAccessSettings.enabled) { + builder.addInterceptor( + CloudflareAccessInterceptor( + cfAccessSettings.clientId, + cfAccessSettings.clientSecret + ) + ) + } val loader = ImageLoader.Builder(context) .okHttpClient(builder.build()) .diskCache { @@ -106,7 +119,7 @@ object CoilInstance { add(DataDecoderFactory()) } .build() - return sslSettings to loader + return Triple(sslSettings, cfAccessSettings, loader) } } diff --git a/app/src/main/kotlin/com/github/gotify/Settings.kt b/app/src/main/kotlin/com/github/gotify/Settings.kt index 243c6c72..2db59017 100644 --- a/app/src/main/kotlin/com/github/gotify/Settings.kt +++ b/app/src/main/kotlin/com/github/gotify/Settings.kt @@ -45,6 +45,15 @@ internal class Settings(context: Context) { var clientCertPassword: String? get() = sharedPreferences.getString("clientCertPass", null) set(value) = sharedPreferences.edit { putString("clientCertPass", value) } + var cfAccessEnabled: Boolean + get() = sharedPreferences.getBoolean("cfAccessEnabled", false) + set(value) = sharedPreferences.edit { putBoolean("cfAccessEnabled", value) } + var cfAccessClientId: String + get() = sharedPreferences.getString("cfAccessClientId", "")!! + set(value) = sharedPreferences.edit { putString("cfAccessClientId", value) } + var cfAccessClientSecret: String + get() = sharedPreferences.getString("cfAccessClientSecret", "")!! + set(value) = sharedPreferences.edit { putString("cfAccessClientSecret", value) } init { sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE) @@ -61,6 +70,9 @@ internal class Settings(context: Context) { caCertPath = null clientCertPath = null clientCertPassword = null + cfAccessEnabled = false + cfAccessClientId = "" + cfAccessClientSecret = "" } fun setUser(name: String?, admin: Boolean) { @@ -76,6 +88,14 @@ internal class Settings(context: Context) { ) } + fun cfAccessSettings(): CfAccessSettings { + return CfAccessSettings( + cfAccessEnabled, + cfAccessClientId, + cfAccessClientSecret + ) + } + @Suppress("UnusedReceiverParameter") private fun Any?.toUnit() = Unit } diff --git a/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt index 9cb550b1..e4ca219e 100644 --- a/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt +++ b/app/src/main/kotlin/com/github/gotify/api/ClientFactory.kt @@ -1,5 +1,6 @@ package com.github.gotify.api +import com.github.gotify.CfAccessSettings import com.github.gotify.SSLSettings import com.github.gotify.Settings import com.github.gotify.client.ApiClient @@ -12,18 +13,23 @@ internal object ClientFactory { private fun unauthorized( settings: Settings, sslSettings: SSLSettings, - baseUrl: String + baseUrl: String, + cfAccessSettings: CfAccessSettings = settings.cfAccessSettings() ): ApiClient { - return defaultClient(arrayOf(), settings, sslSettings, baseUrl) + return defaultClient(arrayOf(), settings, sslSettings, baseUrl, cfAccessSettings) } fun basicAuth( settings: Settings, sslSettings: SSLSettings, username: String, - password: String + password: String, + cfAccessSettings: CfAccessSettings = settings.cfAccessSettings() ): ApiClient { - val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings) + val client = defaultClient( + arrayOf("basicAuth"), settings, sslSettings, + cfAccessSettings = cfAccessSettings + ) val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth auth.username = username auth.password = password @@ -40,9 +46,11 @@ internal object ClientFactory { fun versionApi( settings: Settings, sslSettings: SSLSettings = settings.sslSettings(), - baseUrl: String = settings.url + baseUrl: String = settings.url, + cfAccessSettings: CfAccessSettings = settings.cfAccessSettings() ): VersionApi { - return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java) + return unauthorized(settings, sslSettings, baseUrl, cfAccessSettings) + .createService(VersionApi::class.java) } fun userApiWithToken(settings: Settings): UserApi { @@ -53,10 +61,19 @@ internal object ClientFactory { authentications: Array, settings: Settings, sslSettings: SSLSettings = settings.sslSettings(), - baseUrl: String = settings.url + baseUrl: String = settings.url, + cfAccessSettings: CfAccessSettings = settings.cfAccessSettings() ): ApiClient { val client = ApiClient(authentications) CertUtils.applySslSettings(client.okBuilder, sslSettings) + if (cfAccessSettings.enabled) { + client.okBuilder.addInterceptor( + CloudflareAccessInterceptor( + cfAccessSettings.clientId, + cfAccessSettings.clientSecret + ) + ) + } client.adapterBuilder.baseUrl("$baseUrl/") return client } diff --git a/app/src/main/kotlin/com/github/gotify/api/CloudflareAccessInterceptor.kt b/app/src/main/kotlin/com/github/gotify/api/CloudflareAccessInterceptor.kt new file mode 100644 index 00000000..c8699758 --- /dev/null +++ b/app/src/main/kotlin/com/github/gotify/api/CloudflareAccessInterceptor.kt @@ -0,0 +1,18 @@ +package com.github.gotify.api + +import okhttp3.Interceptor +import okhttp3.Response + +internal class CloudflareAccessInterceptor( + private val clientId: String, + private val clientSecret: String +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + .newBuilder() + .addHeader("CF-Access-Client-Id", clientId) + .addHeader("CF-Access-Client-Secret", clientSecret) + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt index 6b5280df..06550775 100644 --- a/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt +++ b/app/src/main/kotlin/com/github/gotify/login/AdvancedDialog.kt @@ -18,7 +18,7 @@ internal class AdvancedDialog( private lateinit var onClickRemoveCaCertificate: Runnable private lateinit var onClickSelectClientCertificate: Runnable private lateinit var onClickRemoveClientCertificate: Runnable - private lateinit var onClose: (password: String) -> Unit + private lateinit var onClose: (password: String, cfAccessEnabled: Boolean, cfAccessClientId: String, cfAccessClientSecret: String) -> Unit fun onDisableSSLChanged( onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? @@ -47,7 +47,9 @@ internal class AdvancedDialog( return this } - fun onClose(onClose: (password: String) -> Unit): AdvancedDialog { + fun onClose( + onClose: (password: String, cfAccessEnabled: Boolean, cfAccessClientId: String, cfAccessClientSecret: String) -> Unit + ): AdvancedDialog { this.onClose = onClose return this } @@ -57,7 +59,10 @@ internal class AdvancedDialog( caCertPath: String? = null, caCertCN: String?, clientCertPath: String? = null, - clientCertPassword: String? + clientCertPassword: String?, + cfAccessEnabled: Boolean = false, + cfAccessClientId: String = "", + cfAccessClientSecret: String = "" ): AdvancedDialog { binding = AdvancedSettingsDialogBinding.inflate(layoutInflater) binding.disableSSL.isChecked = disableSSL @@ -82,17 +87,39 @@ internal class AdvancedDialog( } else { showRemoveClientCertificate() } + binding.enableCfAccess.isChecked = cfAccessEnabled + setCfAccessFieldsVisibility(cfAccessEnabled) + if (cfAccessClientId.isNotEmpty()) { + binding.cfAccessClientIdEdittext.setText(cfAccessClientId) + } + if (cfAccessClientSecret.isNotEmpty()) { + binding.cfAccessClientSecretEdittext.setText(cfAccessClientSecret) + } + binding.enableCfAccess.setOnCheckedChangeListener { _, isChecked -> + setCfAccessFieldsVisibility(isChecked) + } MaterialAlertDialogBuilder(context) .setView(binding.root) .setTitle(R.string.advanced_settings) .setPositiveButton(context.getString(R.string.done), null) .setOnDismissListener { - onClose(binding.clientCertPasswordEdittext.text.toString()) + onClose( + binding.clientCertPasswordEdittext.text.toString(), + binding.enableCfAccess.isChecked, + binding.cfAccessClientIdEdittext.text.toString(), + binding.cfAccessClientSecretEdittext.text.toString() + ) } .show() return this } + private fun setCfAccessFieldsVisibility(visible: Boolean) { + val visibility = if (visible) android.view.View.VISIBLE else android.view.View.GONE + binding.cfAccessClientIdLayout.visibility = visibility + binding.cfAccessClientSecretLayout.visibility = visibility + } + private fun showSelectCaCertificate() { binding.toggleCaCert.setText(R.string.select_ca_certificate) binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() } diff --git a/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt index df31e7cf..fd5153fa 100644 --- a/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/login/LoginActivity.kt @@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import com.github.gotify.R +import com.github.gotify.CfAccessSettings import com.github.gotify.SSLSettings import com.github.gotify.Settings import com.github.gotify.Utils @@ -51,6 +52,9 @@ internal class LoginActivity : AppCompatActivity() { private var caCertPath: String? = null private var clientCertPath: String? = null private var clientCertPassword: String? = null + private var cfAccessEnabled = false + private var cfAccessClientId = "" + private var cfAccessClientSecret = "" private lateinit var advancedDialog: AdvancedDialog private val caDialogResultLauncher = @@ -145,7 +149,7 @@ internal class LoginActivity : AppCompatActivity() { binding.checkurl.visibility = View.GONE try { - ClientFactory.versionApi(settings, tempSslSettings(), url) + ClientFactory.versionApi(settings, tempSslSettings(), url, tempCfAccessSettings()) .version .enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url))) } catch (e: Exception) { @@ -192,15 +196,21 @@ internal class LoginActivity : AppCompatActivity() { invalidateUrl() clientCertPath = null } - .onClose { newPassword -> + .onClose { newPassword, newCfEnabled, newCfClientId, newCfClientSecret -> clientCertPassword = newPassword + cfAccessEnabled = newCfEnabled + cfAccessClientId = newCfClientId + cfAccessClientSecret = newCfClientSecret } .show( disableSslValidation, caCertPath, caCertCN, clientCertPath, - clientCertPassword + clientCertPassword, + cfAccessEnabled, + cfAccessClientId, + cfAccessClientSecret ) } @@ -254,7 +264,9 @@ internal class LoginActivity : AppCompatActivity() { binding.login.visibility = View.GONE binding.loginProgress.visibility = View.VISIBLE - val client = ClientFactory.basicAuth(settings, tempSslSettings(), username, password) + val client = ClientFactory.basicAuth( + settings, tempSslSettings(), username, password, tempCfAccessSettings() + ) client.createService(UserApi::class.java) .currentUser() .enqueue( @@ -311,6 +323,9 @@ internal class LoginActivity : AppCompatActivity() { settings.caCertPath = caCertPath settings.clientCertPath = clientCertPath settings.clientCertPassword = clientCertPassword + settings.cfAccessEnabled = cfAccessEnabled + settings.cfAccessClientId = cfAccessClientId + settings.cfAccessClientSecret = cfAccessClientSecret Utils.showSnackBar(this, getString(R.string.created_client)) startActivity(Intent(this, InitializationActivity::class.java)) @@ -341,6 +356,14 @@ internal class LoginActivity : AppCompatActivity() { ) } + private fun tempCfAccessSettings(): CfAccessSettings { + return CfAccessSettings( + cfAccessEnabled, + cfAccessClientId, + cfAccessClientSecret + ) + } + private fun copyStreamToFile(inputStream: InputStream, file: File) { FileOutputStream(file).use { inputStream.copyTo(it) diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt index 8c098ed2..78d6972b 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketConnection.kt @@ -5,9 +5,11 @@ import android.app.AlarmManager.OnAlarmListener import android.os.Build import android.os.Handler import android.os.Looper +import com.github.gotify.CfAccessSettings import com.github.gotify.SSLSettings import com.github.gotify.Utils import com.github.gotify.api.CertUtils +import com.github.gotify.api.CloudflareAccessInterceptor import com.github.gotify.client.model.Message import java.util.Calendar import java.util.concurrent.TimeUnit @@ -30,7 +32,8 @@ internal class WebSocketConnection( private val token: String?, private val alarmManager: AlarmManager, private val reconnectDelay: Duration, - private val exponentialBackoff: Boolean + private val exponentialBackoff: Boolean, + cfAccessSettings: CfAccessSettings = CfAccessSettings(false, "", "") ) { companion object { private val ID = AtomicLong(0) @@ -56,6 +59,14 @@ internal class WebSocketConnection( .pingInterval(1, TimeUnit.MINUTES) .connectTimeout(10, TimeUnit.SECONDS) CertUtils.applySslSettings(builder, settings) + if (cfAccessSettings.enabled) { + builder.addInterceptor( + CloudflareAccessInterceptor( + cfAccessSettings.clientId, + cfAccessSettings.clientSecret + ) + ) + } client = builder.build() } diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt index 0e5d6dd0..f1298c88 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -133,7 +133,8 @@ internal class WebSocketService : Service() { settings.token, alarmManager, reconnectDelay, - exponentialBackoff + exponentialBackoff, + settings.cfAccessSettings() ) .onOpen { onOpen() } .onClose { onClose() } diff --git a/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt index 30985e72..9c455378 100644 --- a/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt @@ -19,6 +19,7 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.SwitchPreferenceCompat import com.github.gotify.R +import com.github.gotify.Settings as GotifySettings import com.github.gotify.Utils import com.github.gotify.databinding.SettingsActivityBinding import com.github.gotify.service.WebSocketService @@ -68,8 +69,11 @@ internal class SettingsActivity : } class SettingsFragment : PreferenceFragmentCompat() { + private lateinit var settings: GotifySettings + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.root_preferences, rootKey) + settings = GotifySettings(requireContext()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { findPreference( getString(R.string.setting_key_notification_channels) @@ -98,6 +102,42 @@ internal class SettingsActivity : requestWebSocketRestart() true } + initCfAccessPreferences() + } + + private fun initCfAccessPreferences() { + val cfEnabledPref = findPreference( + getString(R.string.setting_key_cf_access_enabled) + ) + val cfClientIdPref = findPreference( + getString(R.string.setting_key_cf_access_client_id) + ) + val cfClientSecretPref = findPreference( + getString(R.string.setting_key_cf_access_client_secret) + ) + + cfEnabledPref?.isChecked = settings.cfAccessEnabled + cfClientIdPref?.text = settings.cfAccessClientId + cfClientSecretPref?.text = settings.cfAccessClientSecret + + cfEnabledPref?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + settings.cfAccessEnabled = newValue as Boolean + requestWebSocketRestart() + true + } + cfClientIdPref?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + settings.cfAccessClientId = newValue as String + requestWebSocketRestart() + true + } + cfClientSecretPref?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + settings.cfAccessClientSecret = newValue as String + requestWebSocketRestart() + true + } } private fun requestWebSocketRestart() { diff --git a/app/src/main/res/layout/advanced_settings_dialog.xml b/app/src/main/res/layout/advanced_settings_dialog.xml index 643edd5c..21085479 100644 --- a/app/src/main/res/layout/advanced_settings_dialog.xml +++ b/app/src/main/res/layout/advanced_settings_dialog.xml @@ -63,4 +63,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92d715fa..8df88878 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,6 +45,9 @@ Select a Client Certificate File Certificate Password Password required + Enable Cloudflare Access + CF-Access-Client-Id + CF-Access-Client-Secret Please install a file browser Failed to read CA cert: %s Failed to read client cert: %s @@ -124,6 +127,15 @@ Exponentially increase the reconnect delay for each reconnect attempt reconnect_exponential_backoff + Cloudflare Access + Enable Cloudflare Access + Add CF-Access headers to all requests + Client ID + Client Secret + cf_access_enabled + cf_access_client_id + cf_access_client_secret + Gotify foreground notification Min priority messages (<1) Low priority messages (1–3) diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index f61aa774..62f464b4 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -65,4 +65,21 @@ android:defaultValue="true" /> + + + + + + diff --git a/cloudflare-tunnel-access-setup.md b/cloudflare-tunnel-access-setup.md new file mode 100644 index 00000000..be7d4e74 --- /dev/null +++ b/cloudflare-tunnel-access-setup.md @@ -0,0 +1,189 @@ +# Self-Hosted App with Cloudflare Tunnel & Access Protection + +This guide covers how to expose a self-hosted application through a Cloudflare Tunnel and protect it with Cloudflare Access, including service token authentication for API clients. + +--- + +## Prerequisites + +- A domain managed by Cloudflare (e.g. `example.com`) +- `cloudflared` installed and running on your server +- An existing Cloudflare Tunnel configured +- A self-hosted app running on your local network (e.g. `http://192.168.0.105:8080`) + +--- + +## 1. Add the Hostname to Your Tunnel + +Edit your cloudflared configuration file: + +```bash +nano /etc/cloudflared/config.yml +``` + +Add a new hostname entry **before** the catch-all rule: + +```yaml +tunnel: +credentials-file: /root/.cloudflared/.json +ingress: + - hostname: myapp.example.com + service: http://localhost:3000 + - hostname: newapp.example.com + service: http://: + - service: http_status:404 +``` + +> **Important:** The catch-all rule (`- service: http_status:404`) must always be the last entry. + +Restart cloudflared to apply changes: + +```bash +systemctl restart cloudflared +``` + +--- + +## 2. Verify the DNS Record + +When you add a hostname to the tunnel, Cloudflare usually creates the DNS record automatically. Verify it exists: + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. Select your domain → **DNS** → **Records** +3. Confirm a **CNAME** record exists for your subdomain pointing to `.cfargotunnel.com` + +If it's missing, create it manually: + +| Field | Value | +|--------------|-------------------------------------| +| Type | CNAME | +| Name | `newapp` | +| Target | `.cfargotunnel.com` | +| Proxy status | Proxied (orange cloud on) | + +> **Tip:** Check your existing DNS records for other subdomains on the same tunnel — they'll have the same target value. + +--- + +## 3. Protect the App with Cloudflare Access + +### 3.1 Create an Access Application + +1. Go to [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) +2. Navigate to **Access** → **Applications** +3. Click **Add an application** → **Self-hosted** +4. Configure: + - **Application name:** e.g. "My App" + - **Session duration:** How long browser users stay authenticated before re-login (e.g. 24 hours, 7 days) + - **Application domain:** `newapp.example.com` +5. Continue to policies + +> **Note:** Session duration only applies to browser-based users. Service tokens authenticate on every request independently. + +### 3.2 Create a Service Token + +Before adding policies, create a service token for API/programmatic access: + +1. Go to **Access** → **Service Auth** → **Service Tokens** +2. Click **Create Service Token** +3. Give it a name (e.g. "My App API Token") +4. **Save both values immediately:** + - `CF-Access-Client-Id` (ends in `.access`) + - `CF-Access-Client-Secret` + +> **Warning:** The secret is only shown once at creation time. If you lose it, you'll need to create a new token. + +### 3.3 Add the Service Auth Policy + +This policy allows programmatic access via service tokens: + +1. In your Access application, go to the **Policies** tab +2. Click **Add a policy** +3. Configure: + - **Policy name:** e.g. "API Service Token" + - **Action:** **Service Auth** (not Allow) + - **Include** → **Service Token** → select your token +4. Save + +> **Critical:** The action must be **Service Auth**, not **Allow**. If set to Allow, Cloudflare will recognize the token but still redirect to the login page. + +### 3.4 Add a Browser Access Policy (Optional) + +If you also want to access the app from a browser: + +1. **Add a policy** +2. Configure: + - **Policy name:** e.g. "Browser Access" + - **Action:** **Allow** + - **Include** → **Emails** → your email address (or **IP Ranges** for your public IP) +3. Save + +With an email selector, Cloudflare will send you a one-time code to verify your identity. With an IP range selector, access is granted automatically from your network. + +--- + +## 4. Using the Service Token in API Requests + +Add these two headers to every API request: + +``` +CF-Access-Client-Id: .access +CF-Access-Client-Secret: +``` + +### Example with curl + +```bash +curl -H "CF-Access-Client-Id: YOUR_CLIENT_ID" \ + -H "CF-Access-Client-Secret: YOUR_CLIENT_SECRET" \ + https://newapp.example.com/api/endpoint +``` + +### Example with Python (requests) + +```python +import requests + +headers = { + "CF-Access-Client-Id": "YOUR_CLIENT_ID", + "CF-Access-Client-Secret": "YOUR_CLIENT_SECRET", +} + +response = requests.get("https://newapp.example.com/api/endpoint", headers=headers) +``` + +--- + +## 5. Verify the Setup + +Test that the service token works: + +```bash +curl -v \ + -H "CF-Access-Client-Id: YOUR_CLIENT_ID" \ + -H "CF-Access-Client-Secret: YOUR_CLIENT_SECRET" \ + https://newapp.example.com/ +``` + +**Expected result:** HTTP `200` response with your app's content. + +**If you get a `302` redirect to the login page**, check the following: + +- The policy action is **Service Auth**, not **Allow** +- The `CF-Access-Client-Id` and `CF-Access-Client-Secret` header names are exactly correct (case-sensitive) +- The token values match what was generated (the ID ends in `.access`) +- The service token is active and not expired (check in **Access** → **Service Auth**) + +If the token is recognized but still redirects, delete the policy and recreate it with the correct **Service Auth** action. + +--- + +## Summary + +| Step | Where | What | +|------|-------|------| +| 1 | Server | Add hostname to `/etc/cloudflared/config.yml` and restart cloudflared | +| 2 | Cloudflare DNS | Verify CNAME record points to the tunnel | +| 3 | Zero Trust → Access | Create application, service token, and policies | +| 4 | Client app | Add `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers | +| 5 | Terminal | Test with `curl -v` to confirm `200` response |