Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17abcc48c0 | ||
|
|
97ce10dd6f | ||
|
|
3a7ba09ddd |
+3
-2
@@ -128,9 +128,10 @@ def _legacy_to_db(old: LegacyDB) -> DB:
|
|||||||
origins[origin_key(origin)] = True
|
origins[origin_key(origin)] = True
|
||||||
if old.config.auth_host:
|
if old.config.auth_host:
|
||||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||||
if not origins:
|
if not old.config.origins:
|
||||||
# Legacy semantics: no origins configured = the whole rp-id domain
|
# Legacy semantics: no origins configured = the whole rp-id domain
|
||||||
# allowed. The new format requires explicit entries.
|
# allowed, regardless of a dedicated auth host. The new format
|
||||||
|
# requires explicit entries.
|
||||||
origins[f"**.{rp_id}"] = True
|
origins[f"**.{rp_id}"] = True
|
||||||
|
|
||||||
new_config = Config(
|
new_config = Config(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
import base64url
|
import base64url
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from uarite import uaparse
|
||||||
|
|
||||||
from paskia import authcode, db, remoteauth
|
from paskia import authcode, db, remoteauth
|
||||||
from paskia.authcode import CookieCode
|
from paskia.authcode import CookieCode
|
||||||
@@ -23,7 +24,7 @@ from paskia.domains import current_domain, registry
|
|||||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||||
from paskia.fastapi.wschat import authenticate_and_login
|
from paskia.fastapi.wschat import authenticate_and_login
|
||||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||||
from paskia.util import pow, useragent
|
from paskia.util import pow
|
||||||
|
|
||||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
@@ -458,9 +459,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
|||||||
if requesting_domain
|
if requesting_domain
|
||||||
else request.rp_id
|
else request.rp_id
|
||||||
),
|
),
|
||||||
"user_agent_pretty": useragent.compact_user_agent(
|
"user_agent_pretty": uaparse(request.user_agent).pretty,
|
||||||
request.user_agent
|
|
||||||
),
|
|
||||||
"client_ip": request.ip,
|
"client_ip": request.ip,
|
||||||
"action": request.action,
|
"action": request.action,
|
||||||
"pow": {
|
"pow": {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from datetime import datetime
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from uarite import uaparse
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||||
from paskia.util import useragent
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# API structs - inherit from db structs, add uuid for serialization
|
# API structs - inherit from db structs, add uuid for serialization
|
||||||
@@ -124,7 +124,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
|
|||||||
credential_uuid=s.credential_uuid,
|
credential_uuid=s.credential_uuid,
|
||||||
host=s.host,
|
host=s.host,
|
||||||
ip=s.ip,
|
ip=s.ip,
|
||||||
user_agent=useragent.compact_user_agent(s.user_agent),
|
user_agent=uaparse(s.user_agent).pretty,
|
||||||
validated=s.validated,
|
validated=s.validated,
|
||||||
last_renewed=s.validated,
|
last_renewed=s.validated,
|
||||||
is_current=s.key == current_key,
|
is_current=s.key == current_key,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import os
|
|||||||
import re
|
import re
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi_vue.hostutil import parse_endpoints
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
from paskia._version import __version__
|
from paskia._version import __version__
|
||||||
from paskia.domains import auth_host_url, origin_url, partition_origins
|
from paskia.domains import auth_host_url, origin_url, partition_origins
|
||||||
|
from paskia.util import hostutil
|
||||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||||
from paskia.util.hostutil import format_endpoint, wildcard_base
|
from paskia.util.hostutil import format_endpoint, wildcard_base
|
||||||
|
|
||||||
@@ -85,9 +87,47 @@ def _origin_phrase(key: str, rp_id: str) -> str:
|
|||||||
return _compact_url(origin_url(key))
|
return _compact_url(origin_url(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _covered_by_wildcard(key: str, pattern: str) -> bool:
|
||||||
|
"""Whether an origins-table key is redundant given a wildcard key.
|
||||||
|
|
||||||
|
Mirrors DomainConfig matching (sansio._allowlisted): a wildcard covers
|
||||||
|
hostnames under its base over https (any port), except under localhost
|
||||||
|
where any scheme and any port match. Plain http entries outside
|
||||||
|
localhost are therefore never covered and stay listed.
|
||||||
|
"""
|
||||||
|
base = wildcard_base(pattern)
|
||||||
|
if base is None:
|
||||||
|
return False
|
||||||
|
# Keys are bare hosts (https:// and '/' stripped by origin_key, port
|
||||||
|
# kept) or full origins; urlparse needs a scheme or '//' prefix.
|
||||||
|
hostname = urlparse(key if "://" in key else f"//{key}").hostname
|
||||||
|
if not hostname:
|
||||||
|
return False
|
||||||
|
if pattern.startswith("**."):
|
||||||
|
matched = hostutil.is_subdomain(hostname, base)
|
||||||
|
else:
|
||||||
|
# '*.base' covers exactly one subdomain level
|
||||||
|
matched = hostname.endswith(f".{base}") and "." not in hostname[
|
||||||
|
: -len(base) - 1
|
||||||
|
]
|
||||||
|
if not matched:
|
||||||
|
return False
|
||||||
|
if hostutil.is_subdomain(base, "localhost"):
|
||||||
|
return True # localhost: any scheme, any port
|
||||||
|
return "://" not in key or key.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
||||||
"""Compact summary of a domain's in-domain sign-in sites."""
|
"""Compact summary of a domain's in-domain sign-in sites."""
|
||||||
phrases = [_origin_phrase(key, rp_id) for key in sorted(in_domain)]
|
# Prune entries already covered by a reported wildcard (e.g. the auth
|
||||||
|
# host under '**.{rp-id}'); http origins outside localhost survive.
|
||||||
|
wildcards = [k for k in in_domain if wildcard_base(k)]
|
||||||
|
keys = [
|
||||||
|
k
|
||||||
|
for k in in_domain
|
||||||
|
if wildcard_base(k) or not any(_covered_by_wildcard(k, w) for w in wildcards)
|
||||||
|
]
|
||||||
|
phrases = [_origin_phrase(key, rp_id) for key in sorted(keys)]
|
||||||
if len(phrases) > 2:
|
if len(phrases) > 2:
|
||||||
n = len(phrases) - 1
|
n = len(phrases) - 1
|
||||||
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
|
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
from ua_parser import parse
|
|
||||||
|
|
||||||
|
|
||||||
def compact_user_agent(ua: str | None) -> str:
|
|
||||||
"""Format user agent string into a compact display format.
|
|
||||||
|
|
||||||
Returns empty string for empty/missing user agents.
|
|
||||||
Returns original UA for unrecognized ones.
|
|
||||||
"""
|
|
||||||
if not ua or not ua.strip() or ua == "-":
|
|
||||||
return ""
|
|
||||||
r = parse(ua)
|
|
||||||
browser = r.user_agent.family if r.user_agent else None
|
|
||||||
ver = r.user_agent.major if r.user_agent else ""
|
|
||||||
os_name = r.os.family if r.os else None
|
|
||||||
dev = r.device.family if r.device else None
|
|
||||||
# If browser is unrecognized, return original UA
|
|
||||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
|
||||||
return ua
|
|
||||||
# Filter out "Other" values
|
|
||||||
browser = browser if browser and browser != "Other" else ""
|
|
||||||
os_name = os_name if os_name and os_name != "Other" else ""
|
|
||||||
# Exclude device if it's "Other" or matches browser family (parser bug)
|
|
||||||
if dev in (None, "Other") or dev == browser:
|
|
||||||
dev = ""
|
|
||||||
# Build compact string, filtering empty parts
|
|
||||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
|
||||||
result = " ".join(p for p in parts if p).strip()
|
|
||||||
return result
|
|
||||||
+1
-1
@@ -22,8 +22,8 @@ dependencies = [
|
|||||||
"jsondiff>=2.2.1",
|
"jsondiff>=2.2.1",
|
||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"fastapi-vue~=1.4.2",
|
"fastapi-vue~=1.4.2",
|
||||||
"ua-parser[regex]>=1.0.1",
|
|
||||||
"kanta>=0.7.0",
|
"kanta>=0.7.0",
|
||||||
|
"uarite>=0.2.1",
|
||||||
]
|
]
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -941,6 +941,22 @@ class TestLegacyConversion:
|
|||||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||||
assert config.domains["example.com"].origins == {"**.example.com": True}
|
assert config.domains["example.com"].origins == {"**.example.com": True}
|
||||||
|
|
||||||
|
def test_convert_auth_host_with_empty_origins_keeps_wildcard(self, tmp_path):
|
||||||
|
"""A dedicated auth host with no configured origins still allowed
|
||||||
|
the whole rp-id domain in the legacy format — the auth host must
|
||||||
|
not become the only allowed origin."""
|
||||||
|
src_file = tmp_path / "main.db"
|
||||||
|
asyncio.run(
|
||||||
|
_write_legacy(
|
||||||
|
src_file,
|
||||||
|
LegacyConfig(rp_id="example.com", auth_host="auth.example.com"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||||
|
origins = config.domains["example.com"].origins
|
||||||
|
assert origins["**.example.com"] is True
|
||||||
|
assert origins["auth.example.com"] == OriginEntry(auth_host=True)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Transaction log censoring
|
# Transaction log censoring
|
||||||
|
|||||||
Reference in New Issue
Block a user