Compare commits

...

1 Commits

Author SHA1 Message Date
Dominic R
d1c325cb32 admin/files: add CloudFront-backed S3 URL support 2026-04-28 22:50:25 -04:00
13 changed files with 671 additions and 155 deletions

View File

@@ -1,19 +1,70 @@
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from tempfile import SpooledTemporaryFile
from urllib.parse import urlsplit, urlunsplit
from typing import Any
from uuid import UUID
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1, EllipticCurvePrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from django.core.exceptions import ImproperlyConfigured
from django.db import connection
from django.db.models import Q
from django.http.request import HttpRequest
from authentik.admin.files.backends.base import ManageableBackend, get_content_type
from authentik.admin.files.backends.s3_urls import S3UrlOptions, s3_file_url
from authentik.admin.files.usage import FileUsage
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_from_string
_CLOUDFRONT_RSA_KEY_SIZE = 2048
def _validate_cloudfront_private_key(private_key) -> None:
"""Validate a private key against CloudFront signed URL key requirements."""
if isinstance(private_key, RSAPrivateKey):
if private_key.key_size != _CLOUDFRONT_RSA_KEY_SIZE:
raise ImproperlyConfigured(
"CloudFront URL signing keypair must contain a 2048-bit RSA private key, "
"or an ECDSA P-256 private key."
)
return
if isinstance(private_key, EllipticCurvePrivateKey):
if not isinstance(private_key.curve, SECP256R1):
raise ImproperlyConfigured(
"CloudFront URL signing keypair must contain an ECDSA P-256 private key, "
"or a 2048-bit RSA private key."
)
return
raise ImproperlyConfigured(
"CloudFront URL signing keypair must contain an RSA or ECDSA private key."
)
def _cloudfront_private_key_from_keypair(selector: str) -> str:
"""Return the PEM private key for a CloudFront signing Certificate-Key Pair."""
query = Q(name=selector)
try:
query |= Q(kp_uuid=UUID(str(selector)))
except ValueError:
pass
keypair = CertificateKeyPair.objects.filter(query).first()
if keypair is None:
raise ImproperlyConfigured(
"CloudFront URL signing keypair was not found. Configure "
"storage.s3.cloudfront_keypair with a Certificate-Key Pair name or UUID."
)
if not keypair.key_data:
raise ImproperlyConfigured("CloudFront URL signing keypair must include a private key.")
private_key = keypair.private_key
_validate_cloudfront_private_key(private_key)
return keypair.key_data
class S3Backend(ManageableBackend):
"""S3-compatible object storage backend.
@@ -33,7 +84,7 @@ class S3Backend(ManageableBackend):
self._config = {}
self._session = None
def _get_config(self, key: str, default: str | None) -> tuple[str | None, bool]:
def _get_config(self, key: str, default: Any = None) -> tuple[Any, bool]:
unset = object()
current = self._config.get(key, unset)
refreshed = CONFIG.refresh(
@@ -45,6 +96,18 @@ class S3Backend(ManageableBackend):
self._config[key] = refreshed
return (refreshed, current != refreshed)
def _get_config_value(self, key: str, default: Any = None) -> Any:
return CONFIG.get(
f"storage.{self.usage.value}.{self.name}.{key}",
CONFIG.get(f"storage.{self.name}.{key}", default),
)
def _get_config_bool(self, key: str, default: bool = False) -> bool:
return CONFIG.get_bool(
f"storage.{self.usage.value}.{self.name}.{key}",
CONFIG.get_bool(f"storage.{self.name}.{key}", default),
)
@property
def base_path(self) -> str:
"""S3 key prefix: {usage}/{schema}/"""
@@ -52,10 +115,23 @@ class S3Backend(ManageableBackend):
@property
def bucket_name(self) -> str:
return CONFIG.get(
f"storage.{self.usage.value}.{self.name}.bucket_name",
CONFIG.get(f"storage.{self.name}.bucket_name"),
)
return self._get_config_value("bucket_name")
@property
def object_acl(self) -> str | None:
"""ACL applied to uploaded objects, or None to omit ACL entirely."""
object_acl = self._get_config_value("object_acl", "private")
if object_acl in (None, ""):
return None
return object_acl
@property
def cloudfront_private_key(self) -> str | None:
"""Private key loaded from an authentik Certificate-Key Pair."""
keypair = self._get_config_value("cloudfront_keypair", None)
if keypair in (None, ""):
return None
return _cloudfront_private_key_from_keypair(str(keypair))
@property
def session(self) -> boto3.Session:
@@ -84,26 +160,11 @@ class S3Backend(ManageableBackend):
@property
def client(self):
"""Create S3 client with configured endpoint and region."""
endpoint_url = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.endpoint",
CONFIG.get(f"storage.{self.name}.endpoint", None),
)
use_ssl = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.use_ssl",
CONFIG.get(f"storage.{self.name}.use_ssl", True),
)
region_name = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.region",
CONFIG.get(f"storage.{self.name}.region", None),
)
addressing_style = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.addressing_style",
CONFIG.get(f"storage.{self.name}.addressing_style", "auto"),
)
signature_version = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.signature_version",
CONFIG.get(f"storage.{self.name}.signature_version", "s3v4"),
)
endpoint_url = self._get_config_value("endpoint", None)
use_ssl = self._get_config_value("use_ssl", True)
region_name = self._get_config_value("region", None)
addressing_style = self._get_config_value("addressing_style", "auto")
signature_version = self._get_config_value("signature_version", "s3v4")
# Keep signature_version pass-through and let boto3/botocore handle it.
# In boto3's S3 configuration docs, `s3v4` (default) and deprecated `s3`
# are the documented values:
@@ -148,75 +209,29 @@ class S3Backend(ManageableBackend):
request: HttpRequest | None = None,
use_cache: bool = True,
) -> str:
"""Generate presigned URL for file access."""
use_https = CONFIG.get_bool(
f"storage.{self.usage.value}.{self.name}.secure_urls",
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
)
"""Generate a signed or unsigned URL for file access."""
use_https = self._get_config_bool("secure_urls", True)
querystring_auth = self._get_config_bool("querystring_auth", True)
expires_in = int(
timedelta_from_string(
CONFIG.get(
f"storage.{self.usage.value}.{self.name}.url_expiry",
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
)
self._get_config_value("url_expiry", "minutes=15")
).total_seconds()
)
def _file_url(name: str, request: HttpRequest | None) -> str:
client = self.client
params = {
"Bucket": self.bucket_name,
"Key": f"{self.base_path}/{name}",
}
operation_name = "GetObject"
operation_model = client.meta.service_model.operation_model(operation_name)
request_dict = client._convert_to_request_dict(
params,
operation_model,
endpoint_url=client.meta.endpoint_url,
context={"is_presign_request": True},
)
# Support custom domain for S3-compatible storage (so not AWS)
# Well, can't you do custom domains on AWS as well?
custom_domain = CONFIG.get(
f"storage.{self.usage.value}.{self.name}.custom_domain",
CONFIG.get(f"storage.{self.name}.custom_domain", None),
)
if custom_domain:
scheme = "https" if use_https else "http"
path = request_dict["url_path"]
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom_domain must
# include the bucket name (per docs), strip it from the path to avoid
# duplication. See: https://github.com/goauthentik/authentik/issues/19521
# Check with trailing slash to ensure exact bucket name match
if path.startswith(f"/{self.bucket_name}/"):
path = path.removeprefix(f"/{self.bucket_name}")
# Normalize to avoid double slashes
custom_domain = custom_domain.rstrip("/")
if not path.startswith("/"):
path = f"/{path}"
custom_base = urlsplit(f"{scheme}://{custom_domain}")
# Sign the final public URL instead of signing the internal S3 endpoint and
# rewriting it afterwards. Presigned SigV4 URLs include the host header in the
# canonical request, so post-sign host changes break strict backends like RustFS.
public_path = f"{custom_base.path.rstrip('/')}{path}" if custom_base.path else path
request_dict["url_path"] = public_path
request_dict["url"] = urlunsplit(
(custom_base.scheme, custom_base.netloc, public_path, "", "")
)
return client._request_signer.generate_presigned_url(
request_dict,
operation_name,
expires_in=expires_in,
return s3_file_url(
client=self.client,
bucket_name=self.bucket_name,
key=f"{self.base_path}/{name}",
options=S3UrlOptions(
expires_in=expires_in,
custom_domain=self._get_config_value("custom_domain", None),
use_https=use_https,
querystring_auth=querystring_auth,
cloudfront_key_id=self._get_config_value("cloudfront_key_id", None),
cloudfront_private_key=self.cloudfront_private_key,
),
)
if use_cache:
@@ -226,12 +241,15 @@ class S3Backend(ManageableBackend):
def save_file(self, name: str, content: bytes) -> None:
"""Save file to S3."""
extra_args = {}
if self.object_acl is not None:
extra_args["ACL"] = self.object_acl
self.client.put_object(
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
Body=content,
ACL="private",
ContentType=get_content_type(name),
**extra_args,
)
@contextmanager
@@ -241,14 +259,14 @@ class S3Backend(ManageableBackend):
with SpooledTemporaryFile(max_size=5 * 1024 * 1024, suffix=".S3File") as file:
yield file
file.seek(0)
extra_args = {"ContentType": get_content_type(name)}
if self.object_acl is not None:
extra_args["ACL"] = self.object_acl
self.client.upload_fileobj(
Fileobj=file,
Bucket=self.bucket_name,
Key=f"{self.base_path}/{name}",
ExtraArgs={
"ACL": "private",
"ContentType": get_content_type(name),
},
ExtraArgs=extra_args,
)
def delete_file(self, name: str) -> None:

View File

@@ -0,0 +1,133 @@
"""URL helpers for S3-compatible file storage."""
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from urllib.parse import urlsplit, urlunsplit
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
@dataclass(slots=True)
class S3UrlOptions:
"""Options used to generate a browser-facing S3 file URL."""
expires_in: int
custom_domain: str | None
use_https: bool
querystring_auth: bool
cloudfront_key_id: str | None = None
cloudfront_private_key: str | None = None
def get_object_request_dict(client, bucket_name: str, key: str) -> dict:
"""Build botocore's request dict for an S3 GetObject request."""
operation_name = "GetObject"
operation_model = client.meta.service_model.operation_model(operation_name)
return client._convert_to_request_dict(
{
"Bucket": bucket_name,
"Key": key,
},
operation_model,
endpoint_url=client.meta.endpoint_url,
context={"is_presign_request": True},
)
def apply_custom_domain(
request_dict: dict,
bucket_name: str,
custom_domain: str | None,
use_https: bool,
) -> dict:
"""Apply a public custom domain to an S3 request dict."""
if not custom_domain:
return request_dict
scheme = "https" if use_https else "http"
path = request_dict["url_path"]
# When using path-style addressing, the presigned URL contains the bucket
# name in the path (e.g., /bucket-name/key). Since custom domains for
# path-style providers also include the bucket name, strip it from the
# generated path to avoid duplication. See:
# https://github.com/goauthentik/authentik/issues/19521
if path.startswith(f"/{bucket_name}/"):
path = path.removeprefix(f"/{bucket_name}")
custom_base = urlsplit(f"{scheme}://{custom_domain.rstrip('/')}")
if not path.startswith("/"):
path = f"/{path}"
# Sign the final public URL instead of signing the internal S3 endpoint and
# rewriting it afterwards. Presigned SigV4 URLs include the host header in
# the canonical request, so post-sign host changes break strict backends.
public_path = f"{custom_base.path.rstrip('/')}{path}" if custom_base.path else path
request_dict["url_path"] = public_path
request_dict["url"] = urlunsplit((custom_base.scheme, custom_base.netloc, public_path, "", ""))
return request_dict
def cloudfront_signed_url(
url: str,
expires_in: int,
key_id: str,
private_key: str,
) -> str:
"""Sign a CloudFront viewer URL using a canned policy."""
private_key = private_key.replace("\\n", "\n")
key = serialization.load_pem_private_key(private_key.encode("utf-8"), password=None)
def signer(message: bytes) -> bytes:
if isinstance(key, RSAPrivateKey):
return key.sign(message, padding.PKCS1v15(), hashes.SHA1())
if isinstance(key, EllipticCurvePrivateKey):
return key.sign(message, ec.ECDSA(hashes.SHA256()))
raise ValueError("CloudFront URL signing requires an RSA or ECDSA private key")
cloudfront_signer = CloudFrontSigner(key_id, signer)
return cloudfront_signer.generate_presigned_url(
url,
date_less_than=datetime.now(UTC) + timedelta(seconds=expires_in),
)
def s3_file_url(
client,
bucket_name: str,
key: str,
options: S3UrlOptions,
) -> str:
"""Build a signed or unsigned browser-facing URL for an S3 object."""
request_dict = get_object_request_dict(client, bucket_name, key)
request_dict = apply_custom_domain(
request_dict,
bucket_name,
options.custom_domain,
options.use_https,
)
if options.querystring_auth:
return client._request_signer.generate_presigned_url(
request_dict,
"GetObject",
expires_in=options.expires_in,
)
if options.cloudfront_key_id or options.cloudfront_private_key:
if not options.cloudfront_key_id or not options.cloudfront_private_key:
raise ValueError("CloudFront URL signing requires both key_id and private_key")
if not options.custom_domain:
raise ValueError("CloudFront URL signing requires a custom domain")
return cloudfront_signed_url(
request_dict["url"],
options.expires_in,
options.cloudfront_key_id,
options.cloudfront_private_key,
)
return request_dict["url"]

View File

@@ -1,14 +1,108 @@
from unittest import TestCase as UnitTestCase
from unittest import skipUnless
from unittest.mock import Mock, PropertyMock, patch
from urllib.parse import parse_qs, urlsplit
from botocore.exceptions import UnsupportedSignatureVersionError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from authentik.admin.files.backends.s3 import S3Backend
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
from authentik.admin.files.usage import FileUsage
from authentik.lib.config import CONFIG
class TestS3BackendUploadArgs(UnitTestCase):
"""Test S3 upload arguments that don't require a live S3 service."""
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
def test_save_file_includes_private_acl_by_default(self):
client = Mock()
backend = S3Backend(FileUsage.MEDIA)
with (
patch.object(S3Backend, "client", new_callable=PropertyMock, return_value=client),
patch("authentik.admin.files.backends.s3.connection") as connection_mock,
):
connection_mock.schema_name = "public"
backend.save_file("test.png", b"test")
client.put_object.assert_called_once()
self.assertEqual(client.put_object.call_args.kwargs["ACL"], "private")
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
@CONFIG.patch("storage.s3.bucket_name", "test-bucket")
@CONFIG.patch("storage.s3.object_acl", "")
def test_save_file_stream_omits_acl_when_empty_string(self):
client = Mock()
backend = S3Backend(FileUsage.MEDIA)
with (
patch.object(S3Backend, "client", new_callable=PropertyMock, return_value=client),
patch("authentik.admin.files.backends.s3.connection") as connection_mock,
):
connection_mock.schema_name = "public"
with backend.save_file_stream("test.csv") as file:
file.write(b"test")
client.upload_fileobj.assert_called_once()
self.assertNotIn("ACL", client.upload_fileobj.call_args.kwargs["ExtraArgs"])
class TestS3BackendCloudFrontKeypair(UnitTestCase):
"""Test CloudFront signing keypair resolution without a live S3 service."""
def _keypair(self, key_size: int = 2048, private_key=None):
private_key = private_key or rsa.generate_private_key(
public_exponent=65537, key_size=key_size
)
keypair = Mock()
keypair.key_data = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
keypair.private_key = private_key
return keypair
@CONFIG.patch("storage.s3.cloudfront_keypair", "missing")
def test_cloudfront_private_key_requires_existing_keypair(self):
backend = S3Backend(FileUsage.MEDIA)
with patch(
"authentik.admin.files.backends.s3.CertificateKeyPair.objects.filter"
) as filter_mock:
filter_mock.return_value.first.return_value = None
with self.assertRaises(ImproperlyConfigured):
_ = backend.cloudfront_private_key
@CONFIG.patch("storage.s3.cloudfront_keypair", "cloudfront")
def test_cloudfront_private_key_requires_rsa_2048(self):
backend = S3Backend(FileUsage.MEDIA)
keypair = self._keypair(key_size=4096)
with patch(
"authentik.admin.files.backends.s3.CertificateKeyPair.objects.filter"
) as filter_mock:
filter_mock.return_value.first.return_value = keypair
with self.assertRaisesRegex(ImproperlyConfigured, "2048-bit"):
_ = backend.cloudfront_private_key
@CONFIG.patch("storage.s3.cloudfront_keypair", "cloudfront")
def test_cloudfront_private_key_allows_ecdsa_p256(self):
backend = S3Backend(FileUsage.MEDIA)
keypair = self._keypair(private_key=ec.generate_private_key(ec.SECP256R1()))
with patch(
"authentik.admin.files.backends.s3.CertificateKeyPair.objects.filter"
) as filter_mock:
filter_mock.return_value.first.return_value = keypair
self.assertEqual(backend.cloudfront_private_key, keypair.key_data)
@skipUnless(s3_test_server_available(), "S3 test server not available")
class TestS3Backend(FileTestS3BackendMixin, TestCase):
"""Test S3 backend functionality"""
@@ -83,6 +177,25 @@ class TestS3Backend(FileTestS3BackendMixin, TestCase):
self.assertIn("X-Amz-Signature=", url)
self.assertIn("test.png", url)
@CONFIG.patch("storage.s3.querystring_auth", False)
@CONFIG.patch("storage.s3.custom_domain", "assets.example.test")
def test_file_url_unsigned_custom_domain(self):
"""Test file_url can generate stable unsigned CDN URLs."""
url = self.media_s3_backend.file_url("test.png", use_cache=False)
self.assertEqual(url, "https://assets.example.test/media/public/test.png")
@CONFIG.patch("storage.s3.querystring_auth", True)
@CONFIG.patch("storage.media.s3.querystring_auth", False)
@CONFIG.patch("storage.media.s3.custom_domain", "assets.example.test")
def test_file_url_querystring_auth_usage_override(self):
"""Test usage-specific querystring_auth overrides global config."""
media_url = self.media_s3_backend.file_url("test.png", use_cache=False)
reports_url = self.reports_s3_backend.file_url("test.csv", use_cache=False)
self.assertEqual(media_url, "https://assets.example.test/media/public/test.png")
self.assertIn("X-Amz-Signature=", reports_url)
def test_client_signature_version_default_v4(self):
"""Test S3 client defaults to v4 signature when not configured."""
self.assertEqual(self.media_s3_backend.client.meta.config.signature_version, "s3v4")

View File

@@ -0,0 +1,77 @@
from unittest import TestCase
from urllib.parse import parse_qs, urlsplit
import boto3
from botocore.config import Config
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from authentik.admin.files.backends.s3_urls import S3UrlOptions, s3_file_url
def ec_private_key_pem() -> str:
key = ec.generate_private_key(ec.SECP256R1())
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
def s3_client(addressing_style: str = "path"):
return boto3.Session(
aws_access_key_id="accessKey1",
aws_secret_access_key="secretKey1",
).client(
"s3",
endpoint_url="http://localhost:8020",
region_name="us-east-1",
config=Config(
signature_version="s3v4",
s3={"addressing_style": addressing_style},
),
)
class TestS3Urls(TestCase):
def test_unsigned_custom_domain_with_bucket_path(self):
"""Path-style custom domains keep the bucket path once."""
url = s3_file_url(
client=s3_client(),
bucket_name="authentik-data",
key="media/public/logo.png",
options=S3UrlOptions(
expires_in=900,
custom_domain="s3.example.com/authentik-data",
use_https=True,
querystring_auth=False,
),
)
self.assertEqual(url, "https://s3.example.com/authentik-data/media/public/logo.png")
def test_cloudfront_signed_custom_domain_url(self):
"""CloudFront signing signs the viewer-facing custom domain URL."""
url = s3_file_url(
client=s3_client(),
bucket_name="authentik-data",
key="media/public/logo.png",
options=S3UrlOptions(
expires_in=900,
custom_domain="assets.example.com",
use_https=True,
querystring_auth=False,
cloudfront_key_id="K1234567890",
cloudfront_private_key=ec_private_key_pem(),
),
)
parts = urlsplit(url)
params = parse_qs(parts.query)
self.assertEqual(parts.scheme, "https")
self.assertEqual(parts.netloc, "assets.example.com")
self.assertEqual(parts.path, "/media/public/logo.png")
self.assertEqual(params["Key-Pair-Id"], ["K1234567890"])
self.assertIn("Expires", params)
self.assertIn("Signature", params)
self.assertNotIn("X-Amz-Signature", params)

View File

@@ -190,8 +190,12 @@ storage:
# access_key: ""
# secret_key: ""
# bucket_name: "authentik-data"
# object_acl: "private"
# How to render file URLs
# custom_domain: null
# querystring_auth: True
# cloudfront_key_id: null
# cloudfront_keypair: null
secure_urls: True
url_expiry: "minutes=15"
# Usage based settings. Same schema as global settings, overrides global settings

View File

@@ -4,7 +4,7 @@ title: Files
Image files are used in authentik to add icons to new applications or sources, and to define the ["branded" look](../sys-mgmt/brands/index.md#branding-settings) of the authentik interface, with your company's logo and title, a favicon, or a background image for the flows.
authentik provides a centralized file management system for storing and organizing these files. Files can be uploaded and managed from **Customization** > **Files** in the Admin interface. By default, files are stored on disk in the `/data` directory, but [S3 storage](../sys-mgmt/ops/storage-s3.md) can also be configured.
authentik provides a centralized file management system for storing and organizing these files. Files can be uploaded and managed from **Customization** > **Files** in the Admin interface. By default, files are stored on disk in the `/data` directory, but [S3 storage](../sys-mgmt/ops/storage-s3/index.md) can also be configured.
If file uploads are missing or unavailable after an upgrade, see [Errors when uploading icons](../troubleshooting/image_upload.md).

View File

@@ -428,7 +428,7 @@ Defaults to `minutes=15`.
### S3 storage backend settings
For more information on S3 storage, see [S3 storage setup](../../sys-mgmt/ops/storage-s3.md).
For more information on S3 storage, see [S3 storage setup](../../sys-mgmt/ops/storage-s3/index.md).
#### `AUTHENTIK_STORAGE__S3__REGION`
@@ -502,16 +502,50 @@ Defaults to not set.
Name of the bucket to use to store files.
#### `AUTHENTIK_STORAGE__S3__OBJECT_ACL`
Canned ACL to apply to uploaded objects.
Defaults to `private`.
Set to an empty value or `null` to omit the ACL argument. See the [S3 storage setup](../../sys-mgmt/ops/storage-s3/index.md#bucket-creation) docs for AWS buckets with ACLs disabled.
#### `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN`
Domain to use to create URLs for users. Mainly useful for non-AWS providers.
Domain to use to create URLs for users. This can be a CloudFront distribution domain, a CloudFront alternate domain name, a virtual-hosted S3 domain, or a path-style S3-compatible provider domain.
May include a port. Must include the bucket.
May include a port. Include the bucket only when the public URL path needs it, for example with path-style S3-compatible providers.
Example: `s3.company:8080/authentik-data`.
Defaults to not set.
#### `AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH`
Whether generated file URLs should be S3 presigned URLs.
Defaults to `true`.
Set to `false` when files are delivered by a public CDN, by CloudFront with Origin Access Control, or by another layer that performs viewer authorization.
#### `AUTHENTIK_STORAGE__S3__CLOUDFRONT_KEY_ID`
CloudFront public key ID to use for signed viewer URLs.
Only used when `AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false` and `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` is set.
Defaults to not set.
#### `AUTHENTIK_STORAGE__S3__CLOUDFRONT_KEYPAIR`
Name or UUID of the authentik Certificate-Key Pair to use for CloudFront signed viewer URLs.
Only used when `AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false` and `AUTHENTIK_STORAGE__S3__CLOUDFRONT_KEY_ID` is also set.
See [CloudFront signed viewer URLs](../../sys-mgmt/ops/storage-s3/cloudfront.md#configure-cloudfront-signed-viewer-urls) for supported key types and setup steps.
Defaults to not set.
#### `AUTHENTIK_STORAGE__S3__SECURE_URLS`
Whether URLs created use HTTPS or HTTP.

View File

@@ -41,7 +41,7 @@ Other optional pre-installation configurations that you might have already compl
- [Configured your global email settings](../email/#configure-global-email-settings).
- [Configured your PostgreSQL settings](../configuration/configuration.mdx#postgresql-settings) (read-replica, connections, etc.).
- Configured a [reverse proxy](../reverse-proxy.md).
- Configured your [media storage settings](../../install-config/configuration/configuration.mdx#media-storage-settings) or optionally [AWS S3 file storage](../../sys-mgmt/ops/storage-s3.md).
- Configured your [media storage settings](../../install-config/configuration/configuration.mdx#media-storage-settings) or optionally [AWS S3 file storage](../../sys-mgmt/ops/storage-s3/index.md).
- Added additional [custom configurations environment variables](../configuration/#set-your-environment-variables).
- [Verified](../configuration/#verify-your-configuration-settings) your configuration settings.

View File

@@ -116,7 +116,7 @@ slug: /releases/2024.2
- **S3 file storage**
Media files can now be stored on S3. Follow the [setup guide](../../sys-mgmt/ops/storage-s3.md) to get started.
Media files can now be stored on S3. Follow the [setup guide](../../sys-mgmt/ops/storage-s3/index.md) to get started.
- **_Pretend user exists_ option for Identification stage**

View File

@@ -692,7 +692,20 @@ const items = [
items: [
"sys-mgmt/ops/monitoring",
"sys-mgmt/ops/worker",
"sys-mgmt/ops/storage-s3",
{
type: "category",
label: "S3 storage",
collapsed: true,
link: {
type: "doc",
id: "sys-mgmt/ops/storage-s3/index",
},
items: [
"sys-mgmt/ops/storage-s3/index",
"sys-mgmt/ops/storage-s3/standard",
"sys-mgmt/ops/storage-s3/cloudfront",
],
},
"sys-mgmt/ops/geoip",
"sys-mgmt/ops/backup-restore",
],

View File

@@ -0,0 +1,97 @@
---
title: CloudFront delivery
---
CloudFront can sit in front of the S3 bucket while authentik continues to write directly to S3. Use `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` to make authentik return URLs for your CloudFront distribution domain or alternate domain name.
Complete the shared [S3 storage setup](index.md) first.
## Configure CloudFront delivery
Configure CloudFront and any origin access settings in AWS. If you protect the S3 origin with Origin Access Control (OAC), configure the CloudFront-to-S3 bucket policy in AWS as well. This is separate from the browser-facing URLs that authentik returns.
This setup is also the base for [CloudFront signed viewer URLs](#configure-cloudfront-signed-viewer-urls), because authentik signs the URL built from `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN`.
Configure authentik to write directly to S3 and return CloudFront URLs:
```env
AUTHENTIK_STORAGE__BACKEND=s3
AUTHENTIK_STORAGE__S3__REGION=ca-central-1
AUTHENTIK_STORAGE__S3__BUCKET_NAME=authentik-data
AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=assets.authentik.company
AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false
AUTHENTIK_STORAGE__S3__OBJECT_ACL=
```
`AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` can be a CloudFront distribution domain, such as `d111111abcdef8.cloudfront.net`, or an alternate domain name, such as `assets.authentik.company`.
Set `AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false` for CloudFront delivery. This turns off S3 presigned browser URLs. CloudFront access control, if used, is handled by CloudFront, not by S3 presigned URLs from authentik.
You can use the same options globally with `AUTHENTIK_STORAGE__S3__...`, or per usage with `AUTHENTIK_STORAGE__MEDIA__S3__...` and `AUTHENTIK_STORAGE__REPORTS__S3__...`.
## Configure CloudFront signed viewer URLs
CloudFront signed viewer URLs let authentik return temporary CloudFront URLs for distributions that restrict viewer access with trusted key groups.
### Create or import the signing key
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **System** > **Certificates**.
3. Create or upload a Certificate-Key Pair.
4. Use an ECDSA P-256 private key, or import a 2048-bit RSA private key.
**TODO:** authentik currently cannot choose custom RSA key sizes when generating Certificate-Key Pairs. Until that is fixed, use authentik's ECDSA option or import an externally generated RSA 2048 key pair.
### Upload the public key to CloudFront
CloudFront needs the public key, while authentik stores and uses the private key from the Certificate-Key Pair.
**TODO:** authentik currently cannot download only the raw public key from a Certificate-Key Pair. Until that is fixed, download the certificate and extract the public key:
```bash
openssl x509 -pubkey -noout -in cloudfront-certificate.pem > cloudfront-public-key.pem
```
If you generate an RSA 2048 key pair outside authentik, you can create the public key directly:
```bash
openssl genrsa -out cloudfront-private-key.pem 2048
openssl rsa -pubout -in cloudfront-private-key.pem -out cloudfront-public-key.pem
```
Pair that private key with a self-signed certificate before importing it into authentik:
```bash
openssl req -new -x509 \
-key cloudfront-private-key.pem \
-out cloudfront-certificate.pem \
-days 3650 \
-subj "/CN=authentik CloudFront media signing"
```
1. Log in to the AWS console and open **CloudFront**.
2. Navigate to **Public keys** and create a public key from `cloudfront-public-key.pem`.
3. Copy the CloudFront public key ID.
4. Navigate to **Key groups** and create a key group that includes the public key.
5. Edit the behavior that serves authentik files, enable **Restrict viewer access**, and select the trusted key group.
### Configure authentik
Add the CloudFront key ID and the authentik Certificate-Key Pair name or UUID:
```env
AUTHENTIK_STORAGE__BACKEND=s3
AUTHENTIK_STORAGE__S3__REGION=ca-central-1
AUTHENTIK_STORAGE__S3__BUCKET_NAME=authentik-data
AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=assets.authentik.company
AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false
AUTHENTIK_STORAGE__S3__CLOUDFRONT_KEY_ID=K1234567890
AUTHENTIK_STORAGE__S3__CLOUDFRONT_KEYPAIR=cloudfront-media-signing
AUTHENTIK_STORAGE__S3__OBJECT_ACL=
```
CloudFront signed URLs use the same `AUTHENTIK_STORAGE__S3__URL_EXPIRY` duration as other generated storage URLs.
`AUTHENTIK_STORAGE__S3__QUERYSTRING_AUTH=false` disables S3 `X-Amz-*` presigned URLs. CloudFront signed viewer URLs still include CloudFront query parameters such as `Expires`, `Signature`, and `Key-Pair-Id`.
For more options, see the [configuration reference](../../../install-config/configuration/configuration.mdx#s3-storage-backend-settings).

View File

@@ -2,6 +2,13 @@
title: S3 storage setup
---
authentik can store managed files in an S3 bucket instead of the local `/data` directory.
Use this page for shared bucket setup. Then configure authentik with one of these guides:
- [S3 providers and custom domains](standard.md), for private S3 with presigned URLs, S3-compatible providers, and custom domains.
- [CloudFront delivery](cloudfront.md), for AWS S3 buckets delivered through CloudFront, with optional CloudFront signed viewer URLs.
## Preparation
First, create a user on your S3 storage provider and get access credentials (hereafter referred to as `access_key` and `secret_key`).
@@ -14,11 +21,9 @@ The domain you use to access authentik is referred to as `authentik.company` in
You will also need the AWS CLI available locally.
## S3 configuration
## Bucket creation
### Bucket creation
Create the bucket that authentik will use for media files:
Create the bucket that authentik will use for managed files:
```bash
AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider create-bucket --bucket=authentik-data --acl=private
@@ -26,9 +31,9 @@ AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoi
If using AWS S3, you can omit `--endpoint-url`, but you may need to specify `--region`. Some regions require `--create-bucket-configuration LocationConstraint=<region>`.
The bucket ACL is set to private. Depending on your provider you can alternatively disable ACLs and rely on bucket policies.
The bucket ACL is set to private. For AWS buckets with Object Ownership set to **Bucket owner enforced**, ACLs are disabled; omit `--acl private` when creating the bucket and set `AUTHENTIK_STORAGE__S3__OBJECT_ACL=` in authentik.
### Bucket policy
## Bucket policy
The following actions need to be allowed on the bucket:
@@ -73,11 +78,11 @@ The following policy can be used in AWS:
}
```
### CORS policy
## CORS policy
Apply a CORS policy to the bucket, allowing the authentik web interface to access images directly.
Save the following as `cors.json` (use your deployments origin; include scheme and port if nonstandard):
Save the following as `cors.json` (use your deployments origin; include scheme and port if non-standard):
```json
{
@@ -100,7 +105,7 @@ Apply the policy to the bucket:
AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider put-bucket-cors --bucket=authentik-data --cors-configuration=file://cors.json
```
### Content-Type
## Content-Type
Browsers rely on the HTTP `Content-Type` header to determine how to handle files; render HTML, display an image, or perform another action.
@@ -120,52 +125,6 @@ aws s3 cp \
The `Content-Type` header is not set when files are programmatically uploaded to S3 via Terraform.
:::
### Configuring authentik
Add the following to your `.env` file:
```env
AUTHENTIK_STORAGE__BACKEND=s3
AUTHENTIK_STORAGE__S3__ACCESS_KEY=access_key
AUTHENTIK_STORAGE__S3__SECRET_KEY=secret_key
AUTHENTIK_STORAGE__S3__BUCKET_NAME=authentik-data
```
If you are using AWS S3, add:
```env
AUTHENTIK_STORAGE__S3__REGION=us-east-1 # Use the region of the bucket
```
If you are using an S3compatible provider (nonAWS), add:
```env
AUTHENTIK_STORAGE__S3__ENDPOINT=https://s3.provider
AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=s3.provider/authentik-media
```
If your provider only supports legacy S3 signatures, also set:
```env
AUTHENTIK_STORAGE__S3__SIGNATURE_VERSION=s3
```
By default, authentik uses signature version `s3v4`.
The `AUTHENTIK_STORAGE__S3__ENDPOINT` setting controls how authentik communicates with the S3 provider. When set, it overrides region/`USE_SSL`.
The `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` setting controls how media URLs are built for the web interface. It must include the bucket name and must not include a scheme.
For a path-style domain, set `AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=s3.provider/authentik-media`. The object `application-icons/application.png` will be available at `https://s3.provider/authentik-media/application-icons/application.png`.
Whether URLs use HTTPS is controlled by `AUTHENTIK_STORAGE__S3__SECURE_URLS` (defaults to `true`). Depending on your provider, you can also use a virtual hosted-style domain such as `authentik-data.s3.provider`.
:::info
You can omit `ACCESS_KEY` and `SECRET_KEY` when using AWS SDK authentication (instance roles or profiles). See `AUTHENTIK_STORAGE__S3__SESSION_PROFILE` and related options in the configuration reference](../../install-config/configuration/configuration.mdx#storage-settings).
:::
For more options (including `AUTHENTIK_STORAGE__S3__USE_SSL`, session profiles, and security tokens), see the [configuration reference](../../install-config/configuration/configuration.mdx#storage-settings).
## Migrating between storage backends
The following assumes the local storage path is `/data` and the bucket is `authentik-data`. Ensure your `aws` CLI is configured to talk to your provider (add `--endpoint-url` or `--region` as needed).

View File

@@ -0,0 +1,68 @@
---
title: S3 providers and custom domains
---
This page covers the default S3 storage mode: authentik stores objects in S3 and returns S3 URLs to browsers. By default, these URLs are S3 presigned URLs with `X-Amz-*` query parameters.
Complete the shared [S3 storage setup](index.md) first.
## Private S3 with presigned URLs
Add the following to your `.env` file:
```env
AUTHENTIK_STORAGE__BACKEND=s3
AUTHENTIK_STORAGE__S3__ACCESS_KEY=access_key
AUTHENTIK_STORAGE__S3__SECRET_KEY=secret_key
AUTHENTIK_STORAGE__S3__BUCKET_NAME=authentik-data
```
If you are using AWS S3, add:
```env
AUTHENTIK_STORAGE__S3__REGION=us-east-1 # Use the region of the bucket
```
This is the default access mode. authentik stores private objects and returns short-lived S3 presigned URLs with `X-Amz-*` query parameters.
If your provider only supports legacy S3 signatures, also set:
```env
AUTHENTIK_STORAGE__S3__SIGNATURE_VERSION=s3
```
By default, authentik uses signature version `s3v4`.
## S3-compatible providers
If you are using an S3-compatible provider (non-AWS), add:
```env
AUTHENTIK_STORAGE__S3__ENDPOINT=https://s3.provider
```
`AUTHENTIK_STORAGE__S3__ENDPOINT` controls how authentik communicates with the S3 provider. When set, it overrides `AUTHENTIK_STORAGE__S3__REGION` and `AUTHENTIK_STORAGE__S3__USE_SSL`.
## Custom domains
`AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN` controls how file URLs are rendered for browsers. It must not include a scheme.
For a path-style provider domain, include the bucket in the custom domain:
```env
AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=s3.provider/authentik-data
```
The object `application-icons/application.png` will be available at `https://s3.provider/authentik-data/media/public/application-icons/application.png`.
For a virtual-hosted provider domain, use:
```env
AUTHENTIK_STORAGE__S3__CUSTOM_DOMAIN=authentik-data.s3.provider
```
The object will be available at `https://authentik-data.s3.provider/media/public/application-icons/application.png`.
Whether URLs use HTTPS is controlled by `AUTHENTIK_STORAGE__S3__SECURE_URLS` (defaults to `true`).
For more options, see the [configuration reference](../../../install-config/configuration/configuration.mdx#s3-storage-backend-settings).