OAuth/OIDC Login Support added - tested in dev local
Some checks are pending
Docker Image Build and Push / build_self_hosted (push) Waiting to run
Docker Image Build and Push / build_github_hosted_fallback (push) Blocked by required conditions

This commit is contained in:
ChrispyBacon-dev 2025-09-26 15:52:44 +02:00
parent 295cfa634c
commit 43520222a1
16 changed files with 458 additions and 181 deletions

View file

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [v3.0.1] - 2025-09-26
### Added
- **OAuth/OIDC Login Support:** Added support for external authentication providers like Google and Authentik via a new, generic OpenID Connect system.
- **OAuth Documentation:** Created a new help document for setting up OAuth providers.
### Changed
- **Redesigned Login Page:** The login page has been updated with a modern design, provider logos, and a unified flow for both password and OAuth logins.
- **Improved Usability:** The settings page now displays the required Callback URL for OAuth providers, and DockFlare can discover its public hostname from Docker labels.
### Fixed
- Resolved multiple issues with the new OAuth feature, including `redirect_uri_mismatch` errors, a broken "Edit" button, and several backend bugs.
---
## [v3.0.0] - 2025-09-25

View file

@ -89,7 +89,7 @@ try:
logging.info("Successfully connected to Docker daemon.")
except APIError as e:
logging.error(f"FATAL: Docker API error during initial connection: {e}")
docker_client = None # Ensure it's None on APIError too
docker_client = None
except Exception as e:
logging.error(f"FATAL: Failed to connect to Docker daemon: {e}")
docker_client = None
@ -129,17 +129,20 @@ def create_app():
elif oauth_providers:
return redirect(url_for('web.login'))
else:
return redirect(url_for('auth.login'))
return redirect(url_for('web.login'))
# Custom user loader that exempts API routes from authentication checks
@login_manager.request_loader
def load_user_from_request(request):
"""Load user from request - bypass authentication for API endpoints"""
# For API v2 endpoints, don't require Flask-Login authentication
if request.endpoint and request.endpoint.startswith('api_v2.'):
# Create a dummy user to satisfy Flask-Login for API endpoints
"""Load user from request - bypass session auth for designated API endpoints."""
if request.path.startswith('/api/v2/auth/'):
return None
elif request.endpoint and request.endpoint.startswith('api_v2.'):
from app.core.user import User
return User('api_user')
return None
@login_manager.user_loader
@ -181,7 +184,7 @@ def create_app():
logging.info("Web blueprint registered.")
from .web.api_v2_routes import api_v2_bp
# Exclude the API blueprint from CSRF protection
csrf.exempt(api_v2_bp)
app_instance.register_blueprint(api_v2_bp)
logging.info("API v2 blueprint registered.")
@ -191,9 +194,6 @@ def create_app():
app_instance.register_blueprint(setup_bp)
logging.info("Setup blueprint registered.")
from .web.auth_routes import auth_bp
app_instance.register_blueprint(auth_bp)
logging.info("Auth blueprint registered.")
from .web.help_routes import help_bp
app_instance.register_blueprint(help_bp)

View file

@ -20,7 +20,7 @@ import os
import logging
# --- DockFlare Version ---
APP_VERSION = "v3.0"
APP_VERSION = "v3.0.1"
# --- web: https://dockflare.app ---
# --- github: https://github.com/ChrispyBacon-dev/DockFlare ---

View file

@ -246,11 +246,38 @@ def main_application_entrypoint():
logging.error(f"Could not decrypt credentials for provider {provider['name']}. Skipping.")
continue
provider_type = provider.get('type')
issuer_url = provider.get('issuer_url')
if provider_type == 'github':
oauth_instance.register(
name=provider['id'],
client_id=client_id,
client_secret=client_secret,
authorize_url='https://github.com/login/oauth/authorize',
access_token_url='https://github.com/login/oauth/access_token',
api_base_url='https://api.github.com/',
client_kwargs={'scope': 'user:email'}
)
continue
if not issuer_url:
if provider_type == 'google':
issuer_url = 'https://accounts.google.com'
else:
logging.warning(f"Provider 'provider.get('name')' is of type 'provider_type' but is missing an issuer_url. It will be skipped.")
continue
if not issuer_url.endswith('/'):
issuer_url += '/'
metadata_url = f"{issuer_url}.well-known/openid-configuration"
oauth_instance.register(
name=provider['id'],
client_id=client_id,
client_secret=client_secret,
server_metadata_url=f"https://accounts.google.com/.well-known/openid-configuration",
server_metadata_url=metadata_url,
client_kwargs={'scope': 'openid email profile'}
)
@ -276,6 +303,20 @@ def main_application_entrypoint():
load_state()
logging.info("Initial state loading from file complete.")
if docker_client:
try:
container_id = os.getenv('HOSTNAME')
if container_id:
logging.info(f"Attempting to discover public hostname from container ID: {container_id}")
container = docker_client.containers.get(container_id)
hostname_label = container.labels.get('dockflare.hostname')
if hostname_label:
app.config['DOCKFLARE_PUBLIC_HOSTNAME'] = hostname_label
config.DOCKFLARE_PUBLIC_HOSTNAME = hostname_label
logging.info(f"Discovered public hostname from label: {hostname_label}")
except Exception as e:
logging.warning(f"Could not discover public hostname from Docker label: {e}", exc_info=True)
if not docker_client:
logging.error("Docker client is unavailable. Dockflare will operate with limited functionality.")
if tunnel_state: tunnel_state["status_message"] = "Error: Docker client unavailable."

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
{% extends "setup/base.html" %}
{% block setup_content %}
<form method="POST" action="{{ url_for('auth.login') }}{% if request.args.next %}?next={{ request.args.next }}{% endif %}" class="space-y-4">
{{ form.hidden_tag() }}
<div class="form-control">
<label class="label">
<span class="label-text">{{ form.username.label }}</span>
</label>
{{ form.username(class="input input-bordered w-full", placeholder="Your username") }}
{% for error in form.username.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ form.password.label }}</span>
</label>
{{ form.password(class="input input-bordered w-full", placeholder="Your password") }}
{% for error in form.password.errors %}
<label class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</label>
{% endfor %}
</div>
<div class="card-actions mt-6">
{{ form.submit(class="btn btn-primary w-full") }}
</div>
</form>
{% endblock %}

View file

@ -81,7 +81,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-circle" title="Logout">
<a href="{{ url_for('web.logout') }}" class="btn btn-ghost btn-circle" title="Logout">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>

View file

@ -0,0 +1,53 @@
## OAuth Provider Setup
DockFlare allows you to delegate user authentication to external providers using the OpenID Connect (OIDC) standard. This enables single sign-on (SSO) and allows you to integrate with identity providers like Google, Authentik, Okta, and more.
### Adding a New Provider
Follow these steps to add a new OIDC provider:
1. **Navigate to Settings:** From the main dashboard, go to the **Settings** page.
2. **Locate OAuth Section:** Scroll down to the **OAuth Authentication** section.
3. **Add Provider:** Click the **Add Provider** button to open the configuration modal.
You will be presented with the following fields:
* **Provider Type:** This is set to `OpenID Connect (OIDC)`, the modern standard for federated authentication.
* **Issuer URL:** This is the most important field. It is the base URL of your OIDC provider, which DockFlare uses to automatically discover the provider's configuration. For example, `https://accounts.google.com` or `https://authentik.yourdomain.com/application/o/dockflare/`.
* **Provider ID:** A short, unique, lowercase name for this provider (e.g., `google`, `authentik-corp`). This ID is used internally and in the callback URL.
* **Display Name:** The user-friendly name that will appear on the login button (e.g., `Google`, `Corporate SSO`).
* **Client ID:** The public identifier for the DockFlare application, which you will get from your OIDC provider's developer console.
* **Client Secret:** The confidential secret for the DockFlare application, also from your OIDC provider's console.
* **Enable Provider:** This checkbox allows you to enable or disable the provider at any time.
After filling in the details, click **Add Provider** to save.
### Finding Your Callback URL
Once you have added a provider, the required **Callback URL** (also known as an "Authorized redirect URI") will be displayed under the provider's entry on the Settings page.
You must copy this exact URL and add it to your provider's list of allowed callback URLs in their administration console.
---
### Example: Setting up Google
Here is a quick guide to configuring Google as an OAuth provider.
1. **Go to Google Cloud Console:** Navigate to the [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials) page.
2. **Create Credentials:** Click **+ CREATE CREDENTIALS** and select **OAuth client ID**.
3. **Configure Application:**
* Set the **Application type** to **Web application**.
* Give it a name (e.g., "DockFlare").
4. **Add Redirect URI:**
* Under **Authorized redirect URIs**, click **+ ADD URI**.
* Enter the callback URL provided by DockFlare. It will look like this: `https://your-dockflare-domain.com/auth/google/callback`.
5. **Create and Copy:** Click **CREATE**. A window will appear showing your **Client ID** and **Client Secret**. Copy these values.
6. **Configure in DockFlare:**
* **Issuer URL:** `https://accounts.google.com`
* **Provider ID:** `google`
* **Display Name:** `Google`
* **Client ID:** `(Your Client ID from Google)`
* **Client Secret:** `(Your Client Secret from Google)`
Save the provider in DockFlare, and you will be able to log in with your Google account.

View file

@ -19,6 +19,7 @@ This documentation provides comprehensive information for DockFlare. Whether you
* [State-Persistence](State-Persistence.md)
* **Configuration**
* [Container Labels](Container-Labels.md)
* [OAuth Provider Setup](OAuth-Provider-Setup.md)
* **Usage Guide**
* [Basic Usage (Single Domain)](Basic-Usage-Single-Domain.md)
* [Using Multiple Domains (Indexed Labels)](Using-Multiple-Domains-Indexed-Labels.md)

View file

@ -3,28 +3,49 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - DockFlare</title>
<link href="{{ url_for('static', filename='css/output.css') }}" rel="stylesheet">
<title>Sign In - DockFlare</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body class="bg-base-200 flex items-center justify-center h-screen">
<div class="card w-96 bg-base-100 shadow-xl">
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">DockFlare Login</h2>
<div class="text-center mb-6">
<img src="{{ url_for('static', filename='images/logo.gif') }}" alt="DockFlare Logo" class="h-16 mx-auto">
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-2 mb-4">
{% for category, message in messages %}
<div class="alert alert-{{ category if category in ['error', 'success', 'warning', 'info'] else 'info' }} mt-4">
<span>{{ message }}</span>
<div class="alert alert-{{ category if category in ['success', 'error', 'warning', 'info'] else 'info' }} shadow-lg text-sm">
<div>
<span>{{ message }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% if oauth_providers %}
<div class="flex flex-col w-full border-opacity-50">
{% set logos = {
'google': '<svg class="w-5 h-5" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 488 512"><path fill="currentColor" d="M488 261.8C488 403.3 381.5 512 244 512 111.8 512 0 400.2 0 261.8 0 123.8 111.8 14 244 14c77.3 0 143.3 31.4 191.1 82.1l-73.3 73.3c-24.5-23.2-58.3-37.2-97.8-37.2-80.3 0-146.1 65.7-146.1 146.1s65.8 146.1 146.1 146.1c92.8 0 131.3-74.4 136.8-109.9H244v-89.9h239.1c4.9 26.9 7.9 54.9 7.9 84.9z"></path></svg>',
'github': '<svg class="w-5 h-5" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3.3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.3zm37.4 2.6c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.3zM496 256c0 137-111 248-248 248S0 393 0 256 111 8 248 8s248 111 248 248zM248 40c-118.7 0-216 96.1-216 216 0 94.8 61.5 175.2 146.5 203.8 10.8 2.3 14.6-4.6 14.6-10.6 0-5.2-.2-21.5-.3-42.1-60 13.1-72.7-28.7-72.7-28.7-9.8-24.9-24-31.5-24-31.5-19.5-13.3 1.5-13.1 1.5-13.1 21.5 1.5 32.8 22.1 32.8 22.1 19.1 32.8 50.1 23.3 62.3 17.8 2-13.9 7.5-23.3 13.9-28.7-47.5-5.5-97.5-23.6-97.5-105.8 0-23.3 8.4-42.4 22.1-57.4-2.3-5.5-9.6-27.2 2.6-56.5 0 0 18-5.8 59 22.1 17.1-4.8 35.5-7.2 53.9-7.2 18.4 0 36.8 2.4 53.9 7.2 41-27.9 59-22.1 59-22.1 12.2 29.3 4.9 51 2.6 56.5 13.7 15 22.1 34.1 22.1 57.4 0 82.4-50.1 100.3-97.7 105.6 7.7 6.5 14.6 19.5 14.6 39.2 0 28.5-.3 51.5-.3 58.5 0 6.1 3.8 12.9 14.8 10.6 85-28.6 146.5-109 146.5-203.8 0-119.9-97.3-216-216-216z"></path></svg>',
'microsoft': '<svg class="w-5 h-5" aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 32h214.6v214.6H0V32zm233.4 0H448v214.6H233.4V32zM0 265.4h214.6V480H0V265.4zm233.4 0H448V480H233.4V265.4z"></path></svg>'
} %}
{% for provider in oauth_providers %}
<a href="{{ url_for('web.login_provider', provider_id=provider.id) }}" class="btn btn-outline mb-2">
Login with {{ provider.name }}
<a href="{{ url_for('web.login_provider', provider_id=provider.id) }}" class="btn btn-outline justify-start w-full mb-2 font-medium">
<span class="w-6 text-center">
{{ logos[provider.type] | safe if provider.type in logos else '' }}
</span>
<span class="flex-grow text-center">
Sign in with {{ provider.name }}
</span>
</a>
{% endfor %}
{% if password_login_enabled %}
@ -39,26 +60,26 @@
<div class="form-control">
{% if form %}
{{ form.username.label(class="label") }}
{{ form.username(class="input input-bordered", placeholder="Username") }}
{{ form.username(class="input input-bordered w-full", placeholder="Username") }}
{% else %}
<label class="label">Username</label>
<input type="text" name="username" class="input input-bordered" placeholder="Username" required>
<label class="label"><span class="label-text">Username</span></label>
<input type="text" name="username" class="input input-bordered w-full" placeholder="Username" required>
{% endif %}
</div>
<div class="form-control">
{% if form %}
{{ form.password.label(class="label") }}
{{ form.password(class="input input-bordered", placeholder="Password") }}
{{ form.password(class="input input-bordered w-full", placeholder="Password") }}
{% else %}
<label class="label">Password</label>
<input type="password" name="password" class="input input-bordered" placeholder="Password" required>
<label class="label"><span class="label-text">Password</span></label>
<input type="password" name="password" class="input input-bordered w-full" placeholder="Password" required>
{% endif %}
</div>
<div class="form-control mt-6">
{% if form %}
{{ form.submit(class="btn btn-primary") }}
{{ form.submit(class="btn btn-primary w-full") }}
{% else %}
<input type="submit" value="Login" class="btn btn-primary">
<input type="submit" value="Login" class="btn btn-primary w-full">
{% endif %}
</div>
</form>

View file

@ -23,7 +23,7 @@
<script>
(function() {
const seconds = {{ countdown_seconds }};
const redirectUrl = "{{ url_for('auth.login') }}";
const redirectUrl = "{{ url_for('web.login') }}";
const pingUrl = "{{ url_for('web.ping') }}";
const countdownEl = document.getElementById('countdown');
const progressEl = document.getElementById('progress');

View file

@ -715,14 +715,16 @@
(function() {
let authData = null;
// Helper to get CSRF token
function getCsrfToken() {
const csrfInput = document.querySelector('input[name="csrf_token"]');
return csrfInput ? csrfInput.value : '{{ csrf_token() }}';
}
async function loadAuthSettings() {
try {
const response = await fetch('/api/v2/auth/settings', {
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
// No auth header needed, relies on session cookie
const response = await fetch('/api/v2/auth/settings');
if (response.ok) {
authData = await response.json();
@ -730,6 +732,8 @@
renderUsers();
} else {
console.error('Failed to load auth settings');
const providerList = document.getElementById('provider-list');
if(providerList) providerList.innerHTML = '<div class="text-center text-error py-8">Failed to load OAuth settings. Please check browser console for errors.</div>';
}
} catch (error) {
console.error('Error loading auth settings:', error);
@ -738,23 +742,37 @@
function renderProviders() {
const providerList = document.getElementById('provider-list');
if (!providerList) return;
if (!authData || !authData.providers || authData.providers.length === 0) {
providerList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No OAuth providers configured. Click "Add Provider" to get started.</div>';
return;
}
providerList.innerHTML = authData.providers.map(provider => `
providerList.innerHTML = authData.providers.map(provider => {
let callbackUrlHtml = '';
const publicHostname = '{{ DOCKFLARE_PUBLIC_HOSTNAME or "" }}';
if (publicHostname) {
const callbackUrl = `https://${publicHostname}/auth/${provider.id}/callback`;
callbackUrlHtml = `
<div class="text-xs opacity-80 mt-2 pt-2 border-t border-base-200">
<strong class="font-semibold">Callback URL:</strong>
<code class="bg-base-300 p-1 rounded text-xs select-all">${escapeHtml(callbackUrl)}</code>
</div>
`;
}
return `
<div class="card bg-base-100 border">
<div class="card-body p-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="badge badge-${provider.enabled ? 'success' : 'warning'}">${provider.enabled ? 'Enabled' : 'Disabled'}</div>
<div>
<h5 class="font-semibold">${escapeHtml(provider.name)}</h5>
<p class="text-sm opacity-70">${escapeHtml(provider.type)} · ID: ${escapeHtml(provider.id)}</p>
<div class="flex-grow">
<div class="flex items-center space-x-3">
<div class="badge badge-sm ${provider.enabled ? 'badge-success' : 'badge-warning'}">${provider.enabled ? 'Enabled' : 'Disabled'}</div>
<h5 class="font-semibold">${escapeHtml(provider.name)}</h5>
</div>
<p class="text-sm opacity-70 mt-1">${escapeHtml(provider.type)} · ID: ${escapeHtml(provider.id)}</p>
</div>
<div class="flex space-x-2">
<div class="flex flex-shrink-0 space-x-2 ml-4">
<button class="btn btn-sm btn-outline" onclick="editProvider('${escapeHtml(provider.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -767,13 +785,15 @@
</button>
</div>
</div>
${callbackUrlHtml}
</div>
</div>
`).join('');
`}).join('');
}
function renderUsers() {
const userList = document.getElementById('user-list');
if (!userList) return;
if (!authData || !authData.users || authData.users.length === 0) {
userList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No authorized users configured. Click "Add User" to authorize email addresses.</div>';
return;
@ -808,8 +828,121 @@
.replace(/'/g, "&#039;");
}
function handleProviderTypeChange(form, isEdit = false) {
const typeSelect = form.elements['provider_type'];
const issuerInput = form.elements['issuer_url'];
const issuerGroup = issuerInput.closest('.form-control');
const nameInput = form.elements['provider_name'];
const idInput = form.elements['provider_id'];
// Default state
issuerGroup.style.display = 'none';
issuerInput.required = false;
if (typeSelect.value === 'google') {
// Field is hidden, but we still set the value
issuerInput.value = 'https://accounts.google.com';
if (!nameInput.value) nameInput.value = 'Google';
if (!idInput.value && !isEdit) idInput.value = 'google';
} else if (typeSelect.value === 'oidc') {
// Show the field for generic OIDC
issuerGroup.style.display = '';
issuerInput.required = true;
if (!isEdit) {
issuerInput.value = '';
nameInput.value = '';
idInput.value = '';
}
} else if (typeSelect.value === 'github') {
// Field is hidden
if (!nameInput.value) nameInput.value = 'GitHub';
if (!idInput.value && !isEdit) idInput.value = 'github';
}
}
window.showAddProviderModal = function() {
document.getElementById('addProviderModal').showModal();
const modal = document.getElementById('addProviderModal');
const form = modal.querySelector('form');
form.reset();
handleProviderTypeChange(form, false);
modal.showModal();
};
window.editProvider = function(providerId) {
const provider = authData.providers.find(p => p.id === providerId);
if (!provider) {
showToast('Provider not found.', 'error');
return;
}
const modal = document.getElementById('editProviderModal');
const form = document.getElementById('editProviderForm');
const issuerGroup = form.elements['issuer_url'].closest('.form-control');
// Handle visibility of issuer URL field based on the provider's type
if (provider.type === 'github' || provider.type === 'google') {
issuerGroup.style.display = 'none';
form.elements['issuer_url'].required = false;
} else { // oidc
issuerGroup.style.display = '';
form.elements['issuer_url'].required = true;
}
form.elements['provider_type'].value = provider.type || 'oidc';
form.elements['original_provider_id'].value = provider.id;
form.elements['issuer_url'].value = provider.issuer_url || (provider.type === 'google' ? 'https://accounts.google.com' : '');
form.elements['provider_id'].value = provider.id;
form.elements['provider_name'].value = provider.name;
form.elements['client_id'].value = provider.client_id || '';
form.elements['client_id'].placeholder = 'OAuth Client ID';
form.elements['enabled'].checked = provider.enabled;
form.elements['client_secret'].value = '';
modal.showModal();
};
window.updateProvider = async function() {
const form = document.getElementById('editProviderForm');
const providerId = form.elements['original_provider_id'].value;
const providerData = {
name: form.elements['provider_name'].value,
enabled: form.elements['enabled'].checked,
issuer_url: form.elements['issuer_url'].value
};
const clientId = form.elements['client_id'].value;
if (clientId) {
providerData.client_id = clientId;
}
const clientSecret = form.elements['client_secret'].value;
if (clientSecret) {
providerData.client_secret = clientSecret;
}
try {
const response = await fetch(`/api/v2/auth/providers/${providerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(providerData)
});
if (response.ok) {
document.getElementById('editProviderModal').close();
await loadAuthSettings();
showToast('Provider updated successfully!', 'success');
} else {
const error = await response.json();
showToast(error.message || 'Failed to update provider', 'error');
}
} catch (error) {
showToast('Error updating provider', 'error');
}
};
window.showAddUserModal = function() {
@ -833,8 +966,8 @@
const response = await fetch('/api/v2/auth/providers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(providerData)
});
@ -866,8 +999,8 @@
const response = await fetch('/api/v2/auth/users', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(userData)
});
@ -893,8 +1026,8 @@
const response = await fetch(`/api/v2/auth/providers/${providerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
}
});
@ -916,8 +1049,8 @@
const response = await fetch(`/api/v2/auth/users/${encodeURIComponent(userEmail)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
}
});
@ -945,8 +1078,14 @@
}
document.addEventListener('DOMContentLoaded', () => {
if (window.masterApiKey) {
loadAuthSettings();
loadAuthSettings();
const addForm = document.getElementById('addProviderForm');
if (addForm) {
const addTypeSelect = addForm.elements['provider_type'];
if (addTypeSelect) {
addTypeSelect.addEventListener('change', () => handleProviderTypeChange(addForm, false));
}
}
});
})();
@ -962,13 +1101,22 @@
<span class="label-text">Provider Type</span>
</label>
<select name="provider_type" class="select select-bordered" required>
<option value="">Select Provider Type</option>
<option value="google">Google</option>
<option value="google" selected>Google</option>
<option value="github">GitHub</option>
<option value="microsoft">Microsoft</option>
<option value="oidc">Generic OIDC / Authentik</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Issuer URL</span>
</label>
<input name="issuer_url" type="url" placeholder="https://authentik.example.com/application/o/dockflare/" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">The OIDC provider's issuer URL.</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Provider ID</span>
@ -1017,6 +1165,75 @@
</div>
</dialog>
<!-- Edit Provider Modal -->
<dialog id="editProviderModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Edit OAuth Provider</h3>
<form id="editProviderForm" class="space-y-4 mt-4">
<input type="hidden" name="original_provider_id">
<div class="form-control">
<label class="label">
<span class="label-text">Provider Type</span>
</label>
<select name="provider_type" class="select select-bordered" required disabled>
<option value="google">Google</option>
<option value="github">GitHub</option>
<option value="oidc">Generic OIDC / Authentik</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Issuer URL</span>
</label>
<input name="issuer_url" type="url" placeholder="https://authentik.example.com/application/o/dockflare/" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Provider ID</span>
</label>
<input name="provider_id" type="text" placeholder="e.g. google" class="input input-bordered" required disabled />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Display Name</span>
</label>
<input name="provider_name" type="text" placeholder="e.g. Google" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client ID</span>
</label>
<input name="client_id" type="text" placeholder="OAuth Client ID" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client Secret</span>
</label>
<input name="client_secret" type="password" placeholder="Leave blank to keep existing secret" class="input input-bordered" />
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Enable Provider</span>
<input name="enabled" type="checkbox" class="toggle" />
</label>
</div>
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" onclick="updateProvider()">Save Changes</button>
</div>
</div>
</dialog>
<!-- Add User Modal -->
<dialog id="addUserModal" class="modal">
<div class="modal-box">

View file

@ -69,6 +69,14 @@ _AGENT_ENDPOINT_ALLOWLIST = {
'api_v2.agents_post_events',
}
_UI_ENDPOINT_ALLOWLIST = {
'api_v2.manage_auth_settings',
'api_v2.manage_auth_providers',
'api_v2.manage_auth_provider',
'api_v2.manage_auth_users',
'api_v2.manage_auth_user',
}
@api_v2_bp.before_request
def _enforce_master_api_key():
@ -78,11 +86,13 @@ def _enforce_master_api_key():
if request.method == 'OPTIONS':
return
# For agent endpoints in allowlist, skip all authentication (including Flask-Login)
if endpoint in _AGENT_ENDPOINT_ALLOWLIST:
return
# For all other API endpoints, ensure proper API authentication
# For UI endpoints, rely on Flask-Login's session auth
if endpoint in _UI_ENDPOINT_ALLOWLIST:
return
expected_key = current_app.config.get('MASTER_API_KEY') or config.MASTER_API_KEY
if not expected_key:
logging.warning("MASTER_AUTH: Master API key not configured; rejecting %s", endpoint)
@ -1991,8 +2001,11 @@ def manage_auth_settings():
users = config_data.get('authorized_users', [])
for p in providers:
p.pop('client_id', None)
p.pop('client_secret', None)
try:
p['client_id'] = fernet.decrypt(p['client_id'].encode()).decode()
except Exception:
p['client_id'] = '(could not decrypt)'
return jsonify({
"settings": auth_settings,
@ -2036,6 +2049,10 @@ def manage_auth_providers():
if not all(field in data for field in required_fields):
return jsonify({"error": "missing_required_fields"}), 400
provider_type = data.get('type')
if provider_type in ['oidc', 'google'] and not data.get('issuer_url'):
return jsonify({"error": "issuer_url_required_for_oidc"}), 400
if not config_data.get('auth_providers'):
config_data['auth_providers'] = []
@ -2050,6 +2067,7 @@ def manage_auth_providers():
'id': data['id'],
'name': data['name'],
'type': data['type'],
'issuer_url': data.get('issuer_url'),
'client_id': encrypted_client_id,
'client_secret': encrypted_client_secret,
'enabled': data.get('enabled', True)
@ -2088,6 +2106,8 @@ def manage_auth_provider(provider_id):
provider['client_id'] = fernet.encrypt(data['client_id'].encode()).decode()
if 'client_secret' in data:
provider['client_secret'] = fernet.encrypt(data['client_secret'].encode()).decode()
if 'issuer_url' in data:
provider['issuer_url'] = data['issuer_url']
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500

View file

@ -1,74 +0,0 @@
# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels.
# Copyright (C) 2025 ChrispyBacon-Dev <https://github.com/ChrispyBacon-dev/DockFlare>
#
# This program is free software: you can redistribute and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# dockflare/app/web/utils.py
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, abort
from flask_login import login_user, logout_user, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired
from werkzeug.security import check_password_hash
from app.core.user import User
from app.web.utils import is_safe_url
auth_bp = Blueprint('auth', __name__, url_prefix='/auth', template_folder='../templates')
class LoginForm(FlaskForm):
"""Form for the login page."""
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Handles the user login process."""
if current_app.config.get('DISABLE_PASSWORD_LOGIN'):
return redirect(url_for('web.status_page'))
if current_user.is_authenticated:
return redirect(url_for('web.status_page'))
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
stored_username = current_app.config.get('DOCKFLARE_USERNAME')
stored_hash = current_app.config.get('DOCKFLARE_PASSWORD_HASH')
if username == stored_username and stored_hash and check_password_hash(stored_hash, password):
user = User(username)
login_user(user)
next_page = request.args.get('next')
if next_page and not is_safe_url(next_page):
return abort(400)
return redirect(next_page or url_for('web.status_page'))
else:
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html', form=form, title="Login")
@auth_bp.route('/logout')
def logout():
"""Handles the user logout process."""
logout_user()
flash('You have been logged out.', 'success')
if current_app.config.get('DISABLE_PASSWORD_LOGIN'):
return redirect(url_for('web.status_page'))
return redirect(url_for('auth.login'))

View file

@ -187,9 +187,22 @@ def gating_logic():
@bp.before_app_request
def detect_protocol_bp():
forwarded_proto = request.headers.get('X-Forwarded-Proto', '').lower()
current_app.config['PREFERRED_URL_SCHEME'] = 'https' if forwarded_proto == 'https' or request.is_secure else 'http'
if forwarded_proto == 'https':
current_app.config['PREFERRED_URL_SCHEME'] = 'https'
return
cf_visitor = request.headers.get('Cf-Visitor')
if cf_visitor:
try:
visitor_data = json.loads(cf_visitor)
if visitor_data.get('scheme') == 'https':
current_app.config['PREFERRED_URL_SCHEME'] = 'https'
return
except (json.JSONDecodeError, TypeError):
pass
current_app.config['PREFERRED_URL_SCHEME'] = 'https' if request.is_secure else 'http'
@bp.after_app_request
def add_security_headers_bp(response):
@ -232,6 +245,8 @@ def inject_protocol_bp():
if current_user.is_authenticated:
master_key_value = current_app.config.get('MASTER_API_KEY')
public_hostname = current_app.config.get('DOCKFLARE_PUBLIC_HOSTNAME')
return {
'protocol': preferred_scheme,
'is_https': preferred_scheme == 'https',
@ -241,7 +256,8 @@ def inject_protocol_bp():
'app_version': config.APP_VERSION,
'master_api_key': master_key_value,
'oauth_enabled': oauth_enabled,
'current_user_auth_method': getattr(current_user, 'auth_method', None) if current_user.is_authenticated else None
'current_user_auth_method': getattr(current_user, 'auth_method', None) if current_user.is_authenticated else None,
'DOCKFLARE_PUBLIC_HOSTNAME': public_hostname
}
@bp.route('/')
@ -1863,7 +1879,13 @@ def login_provider(provider_id):
session['oauth_state'] = state_token
from app import oauth
callback_url = url_for('web.auth_callback', provider_id=provider_id, _external=True)
public_hostname = current_app.config.get('DOCKFLARE_PUBLIC_HOSTNAME')
if public_hostname:
path = url_for('web.auth_callback', provider_id=provider_id)
callback_url = f"https://{public_hostname}{path}"
logging.info(f"Constructed OAuth callback URL using public hostname: {callback_url}")
else:
callback_url = url_for('web.auth_callback', provider_id=provider_id, _external=True)
return oauth.create_client(provider_id).authorize_redirect(callback_url, state=state_token)
@bp.route('/auth/<provider_id>/callback')
@ -1911,10 +1933,7 @@ def logout():
auth_method = getattr(current_user, 'auth_method', 'password')
logout_user()
if auth_method == 'oauth':
flash('You have been logged out of DockFlare. You may still be logged into your OAuth provider.', 'info')
else:
flash('You have been logged out.', 'success')
flash('You have been logged out.', 'success')
if current_app.config.get('DISABLE_PASSWORD_LOGIN'):
oauth_providers = current_app.config.get('OAUTH_PROVIDERS', [])

View file

@ -24,7 +24,7 @@ flask-login==0.6.3
# via -r dockflare/requirements.in
flask-wtf==1.2.2
# via -r dockflare/requirements.in
Authlib==1.3.0
Authlib==1.6.4
# via OAuth2 integration
idna==3.10
# via requests