Compare commits

..

12 commits

Author SHA1 Message Date
Stefano Bertelli
2f63f4cd17 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
2026-05-09 11:46:19 -05:00
Stefano Bertelli
31b31b7946 ci: exclude weak-sensitive-data-hashing rule from CodeQL
API token hashing uses HMAC-SHA256 on high-entropy tokens (256-bit
random), not passwords. Actual password hashing uses bcrypt.
2026-04-03 00:55:01 -05:00
Stefano Bertelli
604446f8ca fix: use HMAC-SHA256 with secret key for API token hashing 2026-04-03 00:51:38 -05:00
Stefano Bertelli
496334137d fix: replace python-jose with PyJWT to eliminate vulnerable ecdsa dependency 2026-04-03 00:46:36 -05:00
Stefano Bertelli
5c02598a46 fix: address CodeQL findings — sha512 for token hashing, secure tempfile 2026-04-03 00:41:16 -05:00
Stefano Bertelli
aa38c3797e ci: add security policy, CodeQL scanning, enable Dependabot 2026-04-03 00:35:42 -05:00
Stefano Bertelli
87989b899d fix(ci): add contents:write permission for release job to push tags 2026-04-03 00:03:44 -05:00
Stefano Bertelli
bde7a82224 fix(ci): remove container from release job, use ubuntu-latest directly 2026-04-02 23:58:22 -05:00
Stefano Bertelli
aaddb319bc fix(ci): add valkey, mock-oidc services and MOCK_OIDC_HOST env for e2e tests 2026-04-02 23:52:01 -05:00
Stefano Bertelli
2a83cead67 Merge branch 'refs/heads/dev'
All checks were successful
CI / test (push) Successful in 1m5s
CI / release (push) Successful in 36s
CI / docker (push) Has been skipped
2026-04-02 23:43:48 -05:00
Stefano Bertelli
b3f23fd00d fix(ci): install Playwright browsers before e2e tests 2026-04-02 23:39:08 -05:00
Stefano Bertelli
c94b2ed76c ci: add GitHub Actions workflows mirroring Forgejo CI/CD 2026-04-02 23:35:56 -05:00
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")