Merge branch 'main' into dev
All checks were successful
Dev / test (push) Successful in 1m22s
Dev / release (push) Successful in 37s
Dev / docker (push) Has been skipped

This commit is contained in:
Stefano Bertelli 2026-05-09 11:46:19 -05:00
commit 2f63f4cd17
12 changed files with 449 additions and 69 deletions

9
.github/codeql/codeql-config.yml vendored Normal file
View file

@ -0,0 +1,9 @@
name: "WireGUI CodeQL config"
query-filters:
# API token hashing uses HMAC-SHA256 which is appropriate for high-entropy
# tokens (256-bit random). Actual password hashing uses bcrypt.
# CodeQL flags any SHA-family hash as "weak for password hashing" but this
# rule is not applicable to API token lookups.
- exclude:
id: py/weak-sensitive-data-hashing

32
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: CodeQL
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
schedule:
- cron: "0 6 * * 1"
jobs:
analyze:
name: Analyze (Python)
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: python
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

91
.github/workflows/dev.yml vendored Normal file
View file

@ -0,0 +1,91 @@
name: Dev
on:
push:
branches:
- dev
jobs:
release:
runs-on: ubuntu-latest
container:
image: python:3.13-slim
steps:
- name: Install system dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Configure git
run: |
git config user.name "GitHub Actions"
git config user.email "noreply@github.com"
- name: Install uv and dependencies
run: |
pip install uv
uv sync --group dev
- name: Semantic release (rc)
id: semrel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(uv run semantic-release version --print 2>/dev/null || echo "")
if [ -z "$VERSION" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No release needed"
else
uv run semantic-release version
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Released v${VERSION}"
fi
outputs:
new_version: ${{ steps.semrel.outputs.new_version }}
skip: ${{ steps.semrel.outputs.skip }}
docker:
needs: release
if: needs.release.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
fetch-tags: true
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push pre-release image
run: |
VERSION="${{ needs.release.outputs.new_version }}"
IMAGE="ghcr.io/${{ github.repository_owner }}/wiregui"
echo "Building ${IMAGE}:v${VERSION}"
docker build --no-cache \
--build-arg "VERSION=${VERSION}" \
-t "${IMAGE}:v${VERSION}" \
-t "${IMAGE}:dev" \
.
docker push "${IMAGE}:v${VERSION}"
docker push "${IMAGE}:dev"
echo "Pushed ${IMAGE}:v${VERSION}, ${IMAGE}:dev"

241
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,241 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: python:3.13-slim
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: wiregui
POSTGRES_PASSWORD: wiregui
POSTGRES_DB: wiregui
options: >-
--health-cmd "pg_isready -U wiregui"
--health-interval 5s
--health-timeout 5s
--health-retries 5
valkey:
image: valkey/valkey:8
options: >-
--health-cmd "valkey-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
mock-oidc:
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
ports:
- 9000:9000
env:
SERVER_PORT: "9000"
JSON_CONFIG: >
{
"interactiveLogin": true,
"httpServer": "NettyWrapper",
"tokenCallbacks": [
{
"issuerId": "test-idp",
"tokenExpiry": 3600,
"requestMappings": [
{
"requestParam": "scope",
"match": "*",
"claims": {
"sub": "$${claim:sub}",
"email": "$${claim:sub}@test.local",
"name": "Test User"
}
}
]
}
]
}
env:
CI: "true"
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
WG_REDIS_URL: redis://valkey:6379/0
MOCK_OIDC_HOST: mock-oidc
steps:
- name: Install system dependencies
run: |
apt-get update && apt-get install -y --no-install-recommends \
git wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync
- name: Install Playwright browsers
run: uv run playwright install --with-deps chromium
- name: Run unit tests
run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short
- name: Run E2E tests
run: |
uv run alembic upgrade head
uv run pytest tests/e2e/ -v --tb=short
release:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
new_tag: ${{ steps.version.outputs.new_tag }}
new_version: ${{ steps.version.outputs.new_version }}
skip: ${{ steps.version.outputs.skip }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Configure git
run: |
git config user.name "GitHub Actions"
git config user.email "noreply@github.com"
- name: Determine version bump
id: version
shell: bash
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"
CURRENT="${LATEST_TAG#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
BUMP="none"
while IFS= read -r msg; do
case "$msg" in
*"BREAKING CHANGE"*|*"!:"*)
BUMP="major"
break
;;
feat:*|feat\(*)
[ "$BUMP" != "major" ] && BUMP="minor"
;;
fix:*|fix\(*|perf:*|perf\(*|refactor:*|refactor\(*)
[ "$BUMP" = "none" ] && BUMP="patch"
;;
esac
done <<< "$COMMITS"
if [ "$BUMP" = "none" ]; then
echo "No version-relevant commits since ${LATEST_TAG}, skipping release"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$BUMP" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "new_tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Version bump: ${BUMP} -> v${NEW_VERSION}"
- name: Generate changelog
id: changelog
if: steps.version.outputs.skip != 'true'
shell: bash
run: |
LATEST_TAG="${{ steps.version.outputs.latest_tag }}"
NEW_TAG="${{ steps.version.outputs.new_tag }}"
BODY="## ${NEW_TAG}"$'\n\n'
for type_label in "feat:Features" "fix:Bug Fixes" "refactor:Refactoring" "perf:Performance" "docs:Documentation" "chore:Maintenance"; do
prefix="${type_label%%:*}"
label="${type_label#*:}"
MATCHES=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null | grep -E "^${prefix}[:(]" || true)
if [ -n "$MATCHES" ]; then
BODY="${BODY}### ${label}"$'\n\n'
while IFS= read -r line; do
CLEAN=$(echo "$line" | sed -E "s/^${prefix}(\([^)]*\))?:\s*//")
BODY="${BODY}- ${CLEAN}"$'\n'
done <<< "$MATCHES"
BODY="${BODY}"$'\n'
fi
done
echo "${BODY}" > /tmp/changelog.md
echo "Generated changelog for ${NEW_TAG}"
- name: Create tag and release
if: steps.version.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NEW_TAG="${{ steps.version.outputs.new_tag }}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
git push origin "${NEW_TAG}"
gh release create "${NEW_TAG}" \
--title "${NEW_TAG}" \
--notes-file /tmp/changelog.md
docker:
needs: release
if: needs.release.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
run: |
VERSION="${{ needs.release.outputs.new_version }}"
TAG="${{ needs.release.outputs.new_tag }}"
IMAGE="ghcr.io/${{ github.repository_owner }}/wiregui"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
echo "Building ${IMAGE}:${TAG}"
docker build --no-cache \
--build-arg "VERSION=${VERSION}" \
-t "${IMAGE}:${TAG}" \
-t "${IMAGE}:${MAJOR}.${MINOR}" \
-t "${IMAGE}:latest" \
.
docker push "${IMAGE}:${TAG}"
docker push "${IMAGE}:${MAJOR}.${MINOR}"
docker push "${IMAGE}:latest"
echo "Pushed ${IMAGE}:${TAG}, ${IMAGE}:${MAJOR}.${MINOR}, ${IMAGE}:latest"

39
SECURITY.md Normal file
View file

@ -0,0 +1,39 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | :white_check_mark: |
## Reporting a Vulnerability
If you discover a security vulnerability in WireGUI, please report it responsibly through **GitHub's private vulnerability reporting**:
1. Go to the [Security Advisories](https://github.com/bartei/wiregui/security/advisories) page
2. Click **"Report a vulnerability"**
3. Fill in the details of the vulnerability
Please **do not** open a public issue for security vulnerabilities.
## What to Expect
- You will receive an acknowledgment within **48 hours**
- We will provide a timeline for a fix within **7 days**
- Security patches will be released as soon as possible
## Scope
The following are in scope for security reports:
- Authentication and authorization bypasses
- SQL injection, XSS, CSRF, or other injection vulnerabilities
- WireGuard configuration issues that could expose private keys
- API token or session handling flaws
- Privilege escalation between user roles
## Out of Scope
- Denial of service (DoS) attacks
- Issues in third-party dependencies (report these upstream)
- Social engineering attacks

View file

@ -20,7 +20,7 @@ dependencies = [
"cryptography>=44", "cryptography>=44",
# Auth # Auth
"bcrypt>=4.0", "bcrypt>=4.0",
"python-jose[cryptography]>=3.3", "pyjwt[crypto]>=2.9",
"authlib>=1.4", "authlib>=1.4",
"pyotp>=2.9", "pyotp>=2.9",
"webauthn>=2.2", "webauthn>=2.2",

View file

@ -166,8 +166,10 @@ async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
async def test_seed_invalid_yaml(clean_config, monkeypatch): async def test_seed_invalid_yaml(clean_config, monkeypatch):
path = Path(tempfile.mktemp(suffix=".yaml")) f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
path.write_text(": : : invalid yaml [[[") f.write(": : : invalid yaml [[[")
f.close()
path = Path(f.name)
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})()) monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
await seed_idp_providers() await seed_idp_providers()
async with async_session() as session: async with async_session() as session:

View file

@ -1,8 +1,6 @@
"""Tests for REST API endpoints and token auth.""" """Tests for REST API endpoints and token auth."""
import hashlib from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
from wiregui.auth.passwords import hash_password from wiregui.auth.passwords import hash_password
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.user import User from wiregui.models.user import User
@ -15,7 +13,7 @@ from wiregui.utils.time import utcnow
def test_generate_api_token(): def test_generate_api_token():
plaintext, token_hash = generate_api_token() plaintext, token_hash = generate_api_token()
assert len(plaintext) > 20 assert len(plaintext) > 20
assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest() assert token_hash == _token_hmac(plaintext)
def test_generate_api_token_unique(): def test_generate_api_token_unique():

70
uv.lock generated
View file

@ -600,18 +600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" },
] ]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.135.2" version = "0.135.2"
@ -1502,6 +1490,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "pyopenssl" name = "pyopenssl"
version = "26.0.0" version = "26.0.0"
@ -1599,25 +1601,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" }, { url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.22" version = "0.0.22"
@ -1797,18 +1780,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@ -1830,15 +1801,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "smmap" name = "smmap"
version = "5.0.3" version = "5.0.3"
@ -2133,8 +2095,8 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "nicegui" }, { name = "nicegui" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyotp" }, { name = "pyotp" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python3-saml" }, { name = "python3-saml" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "qrcode", extra = ["pil"] }, { name = "qrcode", extra = ["pil"] },
@ -2165,8 +2127,8 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "nicegui", specifier = ">=2.12" }, { name = "nicegui", specifier = ">=2.12" },
{ name = "pydantic-settings", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.7" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pyotp", specifier = ">=2.9" }, { name = "pyotp", specifier = ">=2.9" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
{ name = "python3-saml", specifier = ">=1.16" }, { name = "python3-saml", specifier = ">=1.16" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" },

View file

@ -1,27 +1,33 @@
"""API token authentication — Bearer token via Authorization header.""" """API token authentication — Bearer token via Authorization header."""
import hashlib import hmac
import secrets import secrets
from loguru import logger from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select from sqlmodel import select
from wiregui.config import get_settings
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.user import User from wiregui.models.user import User
from wiregui.utils.time import utcnow from wiregui.utils.time import utcnow
def _token_hmac(token: str) -> str:
"""Compute a keyed HMAC-SHA256 digest of an API token."""
key = get_settings().secret_key.encode()
return hmac.new(key, token.encode(), "sha256").hexdigest()
def generate_api_token() -> tuple[str, str]: def generate_api_token() -> tuple[str, str]:
"""Generate a new API token. Returns (plaintext_token, token_hash).""" """Generate a new API token. Returns (plaintext_token, token_hash)."""
plaintext = secrets.token_urlsafe(32) plaintext = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(plaintext.encode()).hexdigest() return plaintext, _token_hmac(plaintext)
return plaintext, token_hash
async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None: async def resolve_bearer_token(session: AsyncSession, token: str) -> User | None:
"""Look up a Bearer token and return the associated user, or None.""" """Look up a Bearer token and return the associated user, or None."""
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = _token_hmac(token)
result = await session.execute( result = await session.execute(
select(ApiToken).where(ApiToken.token_hash == token_hash) select(ApiToken).where(ApiToken.token_hash == token_hash)
) )

View file

@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt import jwt
from wiregui.config import get_settings from wiregui.config import get_settings
@ -22,5 +22,5 @@ def decode_access_token(token: str) -> dict | None:
"""Decode and validate a JWT. Returns the payload dict or None if invalid/expired.""" """Decode and validate a JWT. Returns the payload dict or None if invalid/expired."""
try: try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM]) return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except JWTError: except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
return None return None

View file

@ -57,9 +57,9 @@ async def oidc_callback(provider_id: str, request: Request):
id_token = token.get("id_token") id_token = token.get("id_token")
if id_token: if id_token:
try: try:
from jose import jwt as jose_jwt import jwt as pyjwt
# Decode without verification — we already verified during token exchange # Decode without verification — we already verified during token exchange
claims = jose_jwt.get_unverified_claims(id_token) claims = pyjwt.decode(id_token, options={"verify_signature": False})
userinfo = userinfo or {} userinfo = userinfo or {}
if not userinfo.get("email"): if not userinfo.get("email"):
userinfo["email"] = claims.get("email") userinfo["email"] = claims.get("email")