diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..1987e87 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..66e11f8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..d71183b --- /dev/null +++ b/.github/workflows/dev.yml @@ -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" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c33e61 --- /dev/null +++ b/.github/workflows/release.yml @@ -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" \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1c35206 --- /dev/null +++ b/SECURITY.md @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9249fa4..940f936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "cryptography>=44", # Auth "bcrypt>=4.0", - "python-jose[cryptography]>=3.3", + "pyjwt[crypto]>=2.9", "authlib>=1.4", "pyotp>=2.9", "webauthn>=2.2", diff --git a/tests/e2e/test_idp_seed.py b/tests/e2e/test_idp_seed.py index a04368e..8cf6c9e 100644 --- a/tests/e2e/test_idp_seed.py +++ b/tests/e2e/test_idp_seed.py @@ -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): - path = Path(tempfile.mktemp(suffix=".yaml")) - path.write_text(": : : invalid yaml [[[") + f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w") + 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)})()) await seed_idp_providers() async with async_session() as session: diff --git a/tests/test_api.py b/tests/test_api.py index a793e34..2019ad4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,6 @@ """Tests for REST API endpoints and token auth.""" -import hashlib - -from wiregui.auth.api_token import generate_api_token, resolve_bearer_token +from wiregui.auth.api_token import _token_hmac, generate_api_token, resolve_bearer_token from wiregui.auth.passwords import hash_password from wiregui.models.api_token import ApiToken from wiregui.models.user import User @@ -15,7 +13,7 @@ from wiregui.utils.time import utcnow def test_generate_api_token(): plaintext, token_hash = generate_api_token() assert len(plaintext) > 20 - assert token_hash == hashlib.sha256(plaintext.encode()).hexdigest() + assert token_hash == _token_hmac(plaintext) def test_generate_api_token_unique(): diff --git a/uv.lock b/uv.lock index 46be146..c5407d5 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] -[[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]] name = "fastapi" 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" }, ] +[[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]] name = "pyopenssl" 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" }, ] -[[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]] name = "python-multipart" 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" }, ] -[[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]] name = "shellingham" 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" }, ] -[[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]] name = "smmap" version = "5.0.3" @@ -2133,8 +2095,8 @@ dependencies = [ { name = "loguru" }, { name = "nicegui" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyotp" }, - { name = "python-jose", extra = ["cryptography"] }, { name = "python3-saml" }, { name = "pyyaml" }, { name = "qrcode", extra = ["pil"] }, @@ -2165,8 +2127,8 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "nicegui", specifier = ">=2.12" }, { name = "pydantic-settings", specifier = ">=2.7" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" }, { name = "pyotp", specifier = ">=2.9" }, - { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" }, { name = "python3-saml", specifier = ">=1.16" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, diff --git a/wiregui/auth/api_token.py b/wiregui/auth/api_token.py index 125d256..f2ad837 100644 --- a/wiregui/auth/api_token.py +++ b/wiregui/auth/api_token.py @@ -1,27 +1,33 @@ """API token authentication — Bearer token via Authorization header.""" -import hashlib +import hmac import secrets from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select +from wiregui.config import get_settings from wiregui.models.api_token import ApiToken from wiregui.models.user import User 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]: """Generate a new API token. Returns (plaintext_token, token_hash).""" plaintext = secrets.token_urlsafe(32) - token_hash = hashlib.sha256(plaintext.encode()).hexdigest() - return plaintext, token_hash + return plaintext, _token_hmac(plaintext) async def resolve_bearer_token(session: AsyncSession, token: str) -> User | 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( select(ApiToken).where(ApiToken.token_hash == token_hash) ) diff --git a/wiregui/auth/jwt.py b/wiregui/auth/jwt.py index c007917..71c1492 100644 --- a/wiregui/auth/jwt.py +++ b/wiregui/auth/jwt.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta, timezone -from jose import JWTError, jwt +import jwt 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.""" try: return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM]) - except JWTError: + except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): return None diff --git a/wiregui/pages/auth_oidc.py b/wiregui/pages/auth_oidc.py index 5a9ba27..6385d00 100644 --- a/wiregui/pages/auth_oidc.py +++ b/wiregui/pages/auth_oidc.py @@ -57,9 +57,9 @@ async def oidc_callback(provider_id: str, request: Request): id_token = token.get("id_token") if id_token: try: - from jose import jwt as jose_jwt + import jwt as pyjwt # 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 {} if not userinfo.get("email"): userinfo["email"] = claims.get("email")