-
@@ -17,7 +17,9 @@ const props = defineProps({
// Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus
- focusSiblingSelector: { type: String, default: '' }
+ focusSiblingSelector: { type: String, default: '' },
+ // Optional: extra class name(s) for the modal panel
+ panelClass: { type: [String, Array, Object], default: '' }
})
const emit = defineEmits(['close'])
diff --git a/frontend/src/components/ProfilePicture.vue b/frontend/src/components/ProfilePicture.vue
new file mode 100644
index 0000000..b85df79
--- /dev/null
+++ b/frontend/src/components/ProfilePicture.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ProfilePictureEditorModal.vue b/frontend/src/components/ProfilePictureEditorModal.vue
new file mode 100644
index 0000000..b78aff4
--- /dev/null
+++ b/frontend/src/components/ProfilePictureEditorModal.vue
@@ -0,0 +1,489 @@
+
+
+ {{ title }}
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+ Back
+ Delete
+ Save
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue
index 0e93325..eecf57a 100644
--- a/frontend/src/components/ProfileView.vue
+++ b/frontend/src/components/ProfileView.vue
@@ -15,6 +15,9 @@
v-if="authStore.userInfo?.user"
ref="userBasicInfo"
:name="authStore.userInfo.user.display_name"
+ :avatar-url="authStore.userInfo.user.avatar_url"
+ :avatar-render-version="avatarRenderVersion"
+ avatar-clickable
:email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone"
@@ -26,6 +29,7 @@
:role-name="authStore.userInfo.role.display_name"
update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()"
+ @avatar-click="openAvatarDialog"
@edit="openEditDialog"
@keydown="handleUserInfoKeydown"
>
@@ -131,6 +135,15 @@
+
+
showEditDialog.value || showRegLink.value)
+const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
watch(showEditDialog, (open) => {
- if (!open) return
+ if (!open) {
+ return
+ }
const user = authStore.userInfo.user
editName.value = user.display_name ?? ''
editEmail.value = user.email ?? ''
@@ -196,7 +213,28 @@ onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
})
-onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
+onUnmounted(() => {
+ if (updateInterval.value) clearInterval(updateInterval.value)
+})
+
+const currentAvatarEndpoint = computed(() => {
+ const userUuid = authStore.userInfo?.user?.uuid
+ if (!userUuid) return null
+ return `/auth/api/user/${userUuid}/profile.webp`
+})
+
+const openAvatarDialog = () => {
+ showAvatarDialog.value = true
+}
+
+const closeAvatarDialog = () => {
+ showAvatarDialog.value = false
+}
+
+const handleProfilePictureUpdated = async () => {
+ await authStore.loadUserInfo()
+ avatarRenderVersion.value += 1
+}
const addNewCredential = async () => {
try {
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
if (direction === 'down') {
event.preventDefault()
// Move to user info section - always focus edit button first
- focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
+ focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
// ArrowUp at the top does nothing
}
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
if (!direction) return
event.preventDefault()
- const itemSelector = '.mini-btn, .pairing-input'
+ const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first
- focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
+ focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
}
}
@@ -399,6 +437,7 @@ const saveProfile = async () => {
try {
editError.value = ''
saving.value = true
+ let changed = false
const body = {}
if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal
@@ -406,6 +445,9 @@ const saveProfile = async () => {
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
+ changed = true
+ }
+ if (changed) {
await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000)
}
diff --git a/frontend/src/components/UserBasicInfo.vue b/frontend/src/components/UserBasicInfo.vue
index e0f9e41..0fa6a2c 100644
--- a/frontend/src/components/UserBasicInfo.vue
+++ b/frontend/src/components/UserBasicInfo.vue
@@ -1,9 +1,20 @@
-
- π€
-
+
{{ name }}
@@ -42,11 +53,13 @@
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
grid-template-areas:
"picture heading fields"
"picture org fields"
- ". info info";
+ "picture info info";
gap: 0 1rem;
min-width: 0;
}
-.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
+:deep(.user-picture) { grid-area: picture; align-self: stretch; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
diff --git a/oidc.md b/oidc.md
index 03f4cc0..fb290ec 100644
--- a/oidc.md
+++ b/oidc.md
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
- `GET /.well-known/openid-configuration` β Discovery
- `GET /auth/oidc/keys` β Keys (EdDSA)
- `POST /auth/oidc/token` β Exchange/refresh
-- `GET /auth/oidc/userinfo` β User (bearer token)
+- `GET /auth/oidc/userinfo` β User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
- `POST /auth/oidc/backchannel-logout` β Logout
- `POST /auth/api/exchange` β Native auth code β cookie
+## Claims
+
+- `profile` scope may include `name`, `preferred_username`, and `picture`
+- `email` scope may include `email`
+- `groups` is emitted from client-scoped permissions
+
## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
diff --git a/paskia/fastapi/admin/adminapp.py b/paskia/fastapi/admin/adminapp.py
index 68df84f..c3dbe4a 100644
--- a/paskia/fastapi/admin/adminapp.py
+++ b/paskia/fastapi/admin/adminapp.py
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import (
+ avatar,
permutil,
vitedev,
)
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
ApiOrg,
ApiOrgResponse,
ApiPermission,
+ ApiUser,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -79,7 +81,11 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
- users={u.uuid: u for r in roles for u in r.users},
+ users={
+ u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
+ for r in roles
+ for u in r.users
+ },
)
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
diff --git a/paskia/fastapi/admin/users.py b/paskia/fastapi/admin/users.py
index e344f2e..8a9011c 100644
--- a/paskia/fastapi/admin/users.py
+++ b/paskia/fastapi/admin/users.py
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
-from paskia.util import hostutil, permutil
+from paskia.util import avatar, hostutil, permutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiCreateLinkResponse,
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
return MsgspecResponse(
ApiUserDetail(
- user=ApiUser.from_db(user),
+ user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py
index 87f9a54..257ab52 100644
--- a/paskia/fastapi/mainapp.py
+++ b/paskia/fastapi/mainapp.py
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
"name",
"preferred_username",
"email",
+ "picture",
"groups",
"sid",
],
diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py
index 7a663f0..d659001 100644
--- a/paskia/fastapi/oid.py
+++ b/paskia/fastapi/oid.py
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia.db.structs import Session
-from paskia.util import oidjwt
+from paskia.util import avatar, oidjwt
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
@@ -361,6 +361,7 @@ def _build_token_response(
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
+ picture=avatar.current_avatar_url(user.uuid),
groups=groups or None,
auth_time=auth_time,
)
@@ -442,12 +443,15 @@ async def userinfo(
# Build userinfo response based on scope
scope = payload.get("scope", "openid").split()
- response = {"sub": str(user.uuid)}
+ response: dict[str, object] = {"sub": str(user.uuid)}
if "profile" in scope:
response["name"] = user.display_name
if user.preferred_username:
response["preferred_username"] = user.preferred_username
+ picture = avatar.current_avatar_url(user.uuid)
+ if picture:
+ response["picture"] = picture
if "email" in scope and user.email:
response["email"] = user.email
diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py
index 1b5e227..24b8521 100644
--- a/paskia/fastapi/user.py
+++ b/paskia/fastapi/user.py
@@ -3,11 +3,13 @@ from uuid import UUID
from fastapi import (
Body,
FastAPI,
+ File,
HTTPException,
Request,
Response,
+ UploadFile,
)
-from fastapi.responses import JSONResponse
+from fastapi.responses import FileResponse, JSONResponse
from paskia import db
from paskia.authsession import (
@@ -18,12 +20,48 @@ from paskia.authsession import (
from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
-from paskia.util import hostutil
+from paskia.util import avatar, hostutil
from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
+def _can_manage_avatar(ctx, target_user) -> bool:
+ if ctx.user.uuid == target_user.uuid:
+ return True
+
+ if any(p.scope == "auth:admin" for p in ctx.permissions):
+ return True
+
+ return ctx.org.uuid == target_user.org.uuid and any(
+ p.scope == "auth:org:admin" for p in ctx.permissions
+ )
+
+
+def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
+ if not auth:
+ raise authz.AuthException(
+ status_code=401, detail="Authentication Required", mode="login"
+ )
+
+ ctx = session_ctx(auth, request.headers.get("host"))
+ if not ctx:
+ raise authz.AuthException(
+ status_code=401, detail="Session expired", mode="login"
+ )
+
+ user = db.data().users.get(user_uuid)
+ if not user:
+ raise HTTPException(status_code=404, detail="Avatar not found")
+
+ if not _can_manage_avatar(ctx, user):
+ raise authz.AuthException(
+ status_code=403, detail="Insufficient permissions", mode="forbidden"
+ )
+
+ return ctx, user
+
+
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
@@ -103,6 +141,55 @@ async def user_update_info(
return {"status": "ok"}
+@app.get("/{user_uuid}/profile.webp")
+async def serve_avatar(request: Request, user_uuid: UUID):
+ """Serve a user's current avatar with short-lived caching and ETag."""
+ user = db.data().users.get(user_uuid)
+ if not user:
+ raise HTTPException(status_code=404, detail="Avatar not found")
+
+ path = avatar.avatar_path(user_uuid)
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="Avatar not found")
+
+ data = avatar.read_avatar_bytes(user_uuid)
+ if data is None:
+ raise HTTPException(status_code=404, detail="Avatar not found")
+
+ etag = avatar.avatar_etag(data)
+ if request.headers.get("if-none-match") == f'"{etag}"':
+ return Response(status_code=304, headers={"ETag": f'"{etag}"'})
+
+ headers = {
+ "ETag": f'"{etag}"',
+ "Cache-Control": "public, max-age=300",
+ }
+
+ return FileResponse(path, media_type="image/webp", headers=headers)
+
+
+@app.put("/{user_uuid}/profile.webp")
+async def upload_avatar(
+ request: Request,
+ user_uuid: UUID,
+ file: UploadFile = File(...),
+ auth=AUTH_COOKIE,
+):
+ """Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
+ _ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
+ data = await avatar.read_upload(file)
+ avatar.store_avatar(user_uuid, data)
+ return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
+
+
+@app.delete("/{user_uuid}/profile.webp")
+async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
+ """Delete a user's avatar image on the same URL it is served from."""
+ _ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
+ avatar.remove_avatar_file(user_uuid)
+ return {"status": "ok"}
+
+
@app.patch("/theme")
async def user_update_theme(
request: Request,
diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py
index f4a454f..5ce0ca0 100644
--- a/paskia/util/apistructs.py
+++ b/paskia/util/apistructs.py
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
"""User with uuid serialized."""
uuid: UUID
+ avatar_url: str | None = None
@classmethod
- def from_db(cls, u: User) -> ApiUser:
- return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
+ def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
+ return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
class ApiOrg(Org, kw_only=True):
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
user: ApiUser
credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo]
- sessions: dict[bytes, ApiUserSession]
+ sessions: dict[str, ApiUserSession]
permissions: dict[UUID, ApiPermission] = {}
org: ApiOrg | None = None
role: ApiRole | None = None
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
org: ApiOrg
permissions: dict[UUID, Permission]
roles: dict[UUID, Role]
- users: dict[UUID, User]
+ users: dict[UUID, ApiUser]
class ApiSettings(msgspec.Struct):
diff --git a/paskia/util/avatar.py b/paskia/util/avatar.py
new file mode 100644
index 0000000..8dc5390
--- /dev/null
+++ b/paskia/util/avatar.py
@@ -0,0 +1,102 @@
+"""Avatar storage and URL helpers."""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import os
+from pathlib import Path
+from uuid import UUID
+
+from fastapi import HTTPException, UploadFile
+
+from paskia.util import hostutil
+
+MAX_UPLOAD_BYTES = 10 * 1024 * 1024
+
+
+def media_root() -> Path:
+ """Return the filesystem root for auxiliary media files."""
+ db_path = Path(os.environ.get("PASKIA_DB", "localhost.paskiadb")).resolve()
+ db_name = db_path.name
+ hostname = db_name.removesuffix(".paskiadb") or db_path.stem
+ return db_path.parent / f"{hostname}.data"
+
+
+def avatars_root() -> Path:
+ """Return the filesystem root for stored avatar images."""
+ return media_root() / "user"
+
+
+def avatar_path(user_uuid: UUID) -> Path:
+ """Return the avatar file path for a user."""
+ return avatars_root() / str(user_uuid) / "profile.webp"
+
+
+def avatar_public_path(user_uuid: UUID) -> str:
+ """Return the public relative path for a user's avatar."""
+ return f"/auth/api/user/{user_uuid}/profile.webp"
+
+
+def avatar_browser_url(user_uuid: UUID) -> str | None:
+ """Return the browser-facing avatar URL."""
+ if not avatar_path(user_uuid).is_file():
+ return None
+ return avatar_public_path(user_uuid)
+
+
+def avatar_url(user_uuid: UUID) -> str | None:
+ """Return the absolute public avatar URL for a user, or None."""
+ if not avatar_path(user_uuid).is_file():
+ return None
+ return f"{hostutil.auth_site_url()}api/user/{user_uuid}/profile.webp"
+
+
+def current_avatar_url(user_uuid: UUID) -> str | None:
+ """Return the current absolute avatar URL for a user UUID."""
+ return avatar_url(user_uuid)
+
+
+def remove_avatar_file(user_uuid: UUID) -> None:
+ """Delete a stored avatar file if it exists."""
+ with contextlib.suppress(FileNotFoundError):
+ avatar_path(user_uuid).unlink()
+
+
+def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
+ """Read the stored avatar file for a user, if present."""
+ path = avatar_path(user_uuid)
+ if not path.is_file():
+ return None
+ return path.read_bytes()
+
+
+def _is_webp(data: bytes) -> bool:
+ """Return True when bytes look like a RIFF WebP file."""
+ return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
+
+
+async def read_upload(upload: UploadFile) -> bytes:
+ """Read an uploaded avatar and require it to already be WebP."""
+ data = await upload.read(MAX_UPLOAD_BYTES + 1)
+ if not data:
+ raise HTTPException(status_code=400, detail="No avatar file uploaded")
+ if len(data) > MAX_UPLOAD_BYTES:
+ raise HTTPException(status_code=413, detail="Avatar upload too large")
+
+ if not _is_webp(data):
+ raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
+
+ return data
+
+
+def store_avatar(user_uuid: UUID, data: bytes) -> None:
+ """Store avatar bytes."""
+ path = avatar_path(user_uuid)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+
+
+def avatar_etag(data: bytes) -> str:
+ """Return a stable ETag value for avatar bytes."""
+ return hashlib.sha256(data).hexdigest()[:16]
diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py
index a627003..7c3de54 100644
--- a/paskia/util/oidjwt.py
+++ b/paskia/util/oidjwt.py
@@ -78,6 +78,7 @@ def create_id_token(
name: str | None = None,
preferred_username: str | None = None,
email: str | None = None,
+ picture: str | None = None,
groups: list[str] | None = None,
auth_time: datetime | None = None,
expires_in: int = 3600,
@@ -93,6 +94,7 @@ def create_id_token(
name: User's display name
preferred_username: User's preferred username
email: User's email address
+ picture: User avatar URL
groups: List of permission scopes (groups claim)
auth_time: When the user authenticated (last credential use time)
expires_in: Token lifetime in seconds
@@ -101,8 +103,9 @@ def create_id_token(
Signed JWT string
"""
_ensure_key()
+ assert _private_key is not None
now = datetime.now(UTC)
- payload = {
+ payload: dict[str, object] = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
@@ -119,6 +122,8 @@ def create_id_token(
payload["preferred_username"] = preferred_username
if email:
payload["email"] = email
+ if picture:
+ payload["picture"] = picture
if groups:
payload["groups"] = groups
if auth_time:
@@ -147,8 +152,9 @@ def create_access_token(
Signed JWT string
"""
_ensure_key()
+ assert _private_key is not None
now = datetime.now(UTC)
- payload = {
+ payload: dict[str, object] = {
"iss": issuer,
"sub": str(subject),
"aud": audience,
@@ -173,20 +179,24 @@ def decode_access_token(
Decoded payload or None if invalid
"""
_ensure_key()
+ assert _public_key is not None
try:
- # PyJWT requires audience parameter when token has aud claim.
- # When audience is None, we skip PyJWT's audience validation and validate manually.
- options = {}
- decode_kwargs = {
- "algorithms": ["EdDSA"],
- "issuer": issuer,
- }
if audience is not None:
- decode_kwargs["audience"] = audience
- else:
- options["verify_aud"] = False
+ return jwt.decode(
+ token,
+ _public_key,
+ algorithms=["EdDSA"],
+ issuer=issuer,
+ audience=audience,
+ )
- return jwt.decode(token, _public_key, options=options, **decode_kwargs)
+ return jwt.decode(
+ token,
+ _public_key,
+ algorithms=["EdDSA"],
+ issuer=issuer,
+ options={"verify_aud": False},
+ )
except jwt.PyJWTError:
return None
@@ -212,8 +222,9 @@ def create_logout_token(
Signed JWT string
"""
_ensure_key()
+ assert _private_key is not None
now = datetime.now(UTC)
- payload = {
+ payload: dict[str, object] = {
"iss": issuer,
"aud": audience,
"iat": int(now.timestamp()),
diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py
index b01d73b..0db6abf 100644
--- a/paskia/util/userinfo.py
+++ b/paskia/util/userinfo.py
@@ -2,7 +2,7 @@
from paskia import aaguid, db
from paskia.db import SessionContext
-from paskia.util import hostutil
+from paskia.util import avatar, hostutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiOrg,
@@ -56,7 +56,7 @@ async def build_user_info(
}
return ApiUserDetail(
- user=ApiUser.from_db(user),
+ user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
diff --git a/tests/conftest.py b/tests/conftest.py
index 869b74d..019f68c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -283,3 +283,27 @@ def create_test_session(
with ops_db._db.transaction("create_test_session"):
session.store(now)
return session.key, token
+
+
+def create_test_image_bytes(
+ *,
+ image_format: str = "WEBP",
+) -> bytes:
+ """Return deterministic test upload bytes without image-library dependencies."""
+ fixtures = {
+ "WEBP": (
+ b"RIFF\x1a\x00\x00\x00WEBPVP8 "
+ b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
+ ),
+ "PNG": (
+ b"\x89PNG\r\n\x1a\n"
+ b"\x00\x00\x00\rIHDR"
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
+ b"\x90wS\xde"
+ ),
+ }
+
+ try:
+ return fixtures[image_format.upper()]
+ except KeyError as exc:
+ raise ValueError(f"Unsupported test image format: {image_format}") from exc
diff --git a/tests/test_admin.py b/tests/test_admin.py
index 33bf194..e3333af 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -14,6 +14,7 @@ These tests cover:
import os
import secrets
from datetime import UTC, datetime
+from urllib.parse import urlsplit
from uuid import UUID
import httpx
@@ -37,7 +38,7 @@ from paskia.db import (
)
from paskia.db.operations import DB
from paskia.util.crypto import hash_secret
-from tests.conftest import auth_headers, create_test_session
+from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
# -------------------- Additional Fixtures --------------------
@@ -238,6 +239,38 @@ class TestAdminOrganizations:
assert "roles" in org_data
assert "users" in org_data
+ @pytest.mark.asyncio
+ async def test_list_orgs_includes_user_avatar_urls(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_org,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Admin org payload should include canonical avatar URLs for listed users."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ upload = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert upload.status_code == 200
+
+ response = await client.get(
+ "/auth/api/admin/info",
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 200
+
+ data = response.json()
+ listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
+ parts = urlsplit(listed_user["avatar_url"])
+ assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
+ assert parts.query == ""
+
@pytest.mark.asyncio
async def test_list_orgs_with_org_admin(
self,
@@ -902,6 +935,34 @@ class TestAdminUsersInOrg:
data = response.json()
assert "display_name too long" in data["detail"]
+ @pytest.mark.asyncio
+ async def test_admin_can_upload_user_avatar(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user: User,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Admin should be able to upload avatar for a managed user."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
+
+ response = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 200
+
+ detail = await client.get(
+ f"/auth/api/admin/users/{test_user.uuid}",
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert detail.status_code == 200
+ avatar_url = detail.json()["user"]["avatar_url"]
+ parts = urlsplit(avatar_url)
+ assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
+
@pytest.mark.asyncio
async def test_update_user_role_in_org(
self,
diff --git a/tests/test_api.py b/tests/test_api.py
index d951f30..197ae57 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -12,6 +12,7 @@ These tests cover:
import secrets
from datetime import UTC, datetime, timedelta
+from urllib.parse import urlsplit
import httpx
import pytest
@@ -19,8 +20,10 @@ import pytest
from paskia import authcode
from paskia.authsession import EXPIRES
from paskia.db import delete_session
+from paskia.db.structs import Client
+from paskia.util import oidjwt
from paskia.util.passphrase import generate
-from tests.conftest import auth_headers, create_test_session
+from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
class TestSettingsEndpoint:
@@ -46,6 +49,15 @@ class TestSettingsEndpoint:
data = response.json()
assert "ui_base_path" in data
+ @pytest.mark.asyncio
+ async def test_openid_configuration_includes_picture_claim(
+ self, client: httpx.AsyncClient
+ ):
+ """Discovery document should advertise picture claim support."""
+ response = await client.get("/.well-known/openid-configuration")
+ assert response.status_code == 200
+ assert "picture" in response.json()["claims_supported"]
+
class TestValidateEndpoint:
"""Tests for POST /auth/api/validate"""
@@ -294,6 +306,121 @@ class TestUserInfoEndpoint:
data = response.json()
assert "permissions" in data
+ @pytest.mark.asyncio
+ async def test_user_info_includes_avatar_url(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """User info should include the canonical avatar URL when present."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ upload = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert upload.status_code == 200
+
+ response = await client.get(
+ "/auth/api/user-info",
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ avatar_url = data["user"]["avatar_url"]
+ parts = urlsplit(avatar_url)
+ assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
+ assert parts.query == ""
+
+ @pytest.mark.asyncio
+ async def test_avatar_route_returns_304_for_matching_etag(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Avatar route should honor If-None-Match for unchanged avatars."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ upload = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert upload.status_code == 200
+ parts = urlsplit(upload.json()["avatar_url"])
+
+ first = await client.get(parts.path, headers={"Host": "localhost:4401"})
+ assert first.status_code == 200
+
+ response = await client.get(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ headers={
+ "Host": "localhost:4401",
+ "If-None-Match": first.headers["etag"],
+ },
+ )
+ assert response.status_code == 304
+ assert response.headers["etag"] == first.headers["etag"]
+
+
+class TestOidcUserInfoEndpoint:
+ """Tests for OIDC userinfo metadata relevant to avatars."""
+
+ @pytest.mark.asyncio
+ async def test_userinfo_includes_picture_claim(
+ self,
+ client: httpx.AsyncClient,
+ test_db,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """OIDC userinfo should expose picture when profile scope is granted."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ upload = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert upload.status_code == 200
+ avatar_url = upload.json()["avatar_url"]
+
+ oidc_client, _secret = Client.create(
+ name="Test Client",
+ redirect_uris=["https://client.example/callback"],
+ client_secret="topsecret",
+ )
+ with test_db.transaction("create_test_oidc_client"):
+ test_db.oidc.clients[oidc_client.uuid] = oidc_client
+
+ access_token = oidjwt.create_access_token(
+ issuer="http://localhost:4401",
+ subject=test_user.uuid,
+ audience=str(oidc_client.uuid),
+ scope="openid profile",
+ )
+
+ response = await client.get(
+ "/auth/oidc/userinfo",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Host": "localhost:4401",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
+ assert data["picture"].startswith("http")
+
class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session"""
diff --git a/tests/test_user.py b/tests/test_user.py
index 7350f12..bdf3f98 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -3,16 +3,19 @@ Tests for the user API endpoints (/auth/api/user/).
These tests cover user self-service operations:
- Display name update
+- Avatar upload/delete
- Logout all sessions
- Session management (delete specific session)
- Credential management (delete credential)
- Device addition link creation
"""
+from urllib.parse import urlsplit
+
import httpx
import pytest
-from tests.conftest import auth_headers
+from tests.conftest import auth_headers, create_test_image_bytes
class TestUserDisplayName:
@@ -67,6 +70,138 @@ class TestUserDisplayName:
assert response.status_code == 400
+class TestUserAvatar:
+ """Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
+
+ @pytest.mark.asyncio
+ async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
+ """Uploading avatar without auth should return 401."""
+ response = await client.put(
+ "/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ )
+ assert response.status_code in (401, 404)
+
+ @pytest.mark.asyncio
+ async def test_upload_avatar_success(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Uploading a WebP avatar should store and expose the canonical URL."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ upload_bytes = create_test_image_bytes()
+
+ response = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", upload_bytes, "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ avatar_url = data["avatar_url"]
+ parts = urlsplit(avatar_url)
+ assert parts.query == ""
+
+ avatar_response = await client.get(
+ parts.path,
+ headers={"Host": "localhost:4401"},
+ )
+ assert avatar_response.status_code == 200
+ assert avatar_response.headers["cache-control"] == "public, max-age=300"
+ assert avatar_response.headers["content-type"] == "image/webp"
+ assert "etag" in avatar_response.headers
+ assert avatar_response.content == upload_bytes
+
+ not_modified = await client.get(
+ parts.path,
+ headers={
+ "Host": "localhost:4401",
+ "If-None-Match": avatar_response.headers["etag"],
+ },
+ )
+ assert not_modified.status_code == 304
+ assert not_modified.headers["etag"] == avatar_response.headers["etag"]
+
+ @pytest.mark.asyncio
+ async def test_upload_avatar_rejects_non_webp(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Avatar uploads must already be browser-prepared WebP."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ response = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={
+ "file": (
+ "avatar.png",
+ create_test_image_bytes(image_format="PNG"),
+ "image/png",
+ )
+ },
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Avatar upload must be WebP"
+
+ @pytest.mark.asyncio
+ async def test_delete_avatar_success(
+ self,
+ client: httpx.AsyncClient,
+ session_token: str,
+ test_user,
+ tmp_path,
+ monkeypatch,
+ ):
+ """Deleting avatar should clear the user avatar URL."""
+ monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
+
+ await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+
+ response = await client.delete(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_regular_user_cannot_upload_another_users_avatar(
+ self,
+ client: httpx.AsyncClient,
+ regular_session_token: str,
+ session_token: str,
+ test_user,
+ ):
+ """A non-admin user should not be able to upload another user's avatar."""
+ response = await client.put(
+ f"/auth/api/user/{test_user.uuid}/profile.webp",
+ files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
+ headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
+ )
+ assert response.status_code == 403
+
+ info = await client.get(
+ "/auth/api/user-info",
+ headers={**auth_headers(session_token), "Host": "localhost:4401"},
+ )
+ assert info.status_code == 200
+ assert info.json()["user"].get("avatar_url") is None
+
+
class TestUserLogoutAll:
"""Tests for POST /auth/api/user/logout-all"""