From 9dbb959797c8ee2f96d30c52fbde9e94dd3b3e0f Mon Sep 17 00:00:00 2001 From: Chris Dedman Rollet <61106361+chrisdedman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:59:08 -0700 Subject: [PATCH 1/2] Update CI workflow to trigger on pushes to the Develop branch (#52) From 2c5071b738a6d879a5e6f984cc45bd344b6fa1e1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 30 Aug 2026 16:37:05 -0700 Subject: [PATCH 2/2] Improve Matrix avatar support by implementing OAuth refresh token handling, updating API responses, and adding avatar management features in the frontend. --- docs/docs/architecture/authentication.md | 94 ++++++++- docs/docs/development.md | 3 +- docs/docs/reference/api.md | 57 +++++- frontend/src/api/client.ts | 15 +- frontend/src/components/AvatarImage.vue | 37 ++++ frontend/src/components/ProjectBrowseRow.vue | 24 +-- frontend/src/components/ProjectCard.vue | 24 +-- frontend/src/components/SiteHeader.vue | 44 +---- frontend/src/pages/ProfilePage.vue | 192 ++++++++++++++----- frontend/src/pages/PublicProfilePage.vue | 22 +-- frontend/src/utils/initials.ts | 12 ++ scripts/dev_matrix_tunnel.py | 2 +- 12 files changed, 377 insertions(+), 149 deletions(-) create mode 100644 frontend/src/components/AvatarImage.vue create mode 100644 frontend/src/utils/initials.ts diff --git a/docs/docs/architecture/authentication.md b/docs/docs/architecture/authentication.md index 11ad020..b85a3a4 100644 --- a/docs/docs/architecture/authentication.md +++ b/docs/docs/architecture/authentication.md @@ -40,6 +40,7 @@ sequenceDiagram App->>App: Call homeserver /_matrix/client/v3/account/whoami App-->>App: Homeserver validates the access token through MAS App->>App: Fetch /_matrix/client/v3/profile/{userId} + App->>App: Encrypt and store the OAuth refresh token App-->>Browser: Set application session and redirect to dashboard ``` @@ -67,6 +68,9 @@ as the same account. | Profile | Public | Community-facing identity | | Matrix ID | Resolved from the homeserver | Authoritative Matrix account address | | Matrix ID verification | Server-controlled | Confirms the `whoami` result for the login token | +| Matrix avatar `mxc://` URI | Private | Source for the authenticated avatar proxy | +| Encrypted Matrix refresh token | Private | Obtains a short-lived token for the avatar proxy | +| Custom avatar URL | Public | An image the user chose, which overrides the Matrix avatar | A profile's Matrix ID is populated from the homeserver `whoami` response during login. API clients cannot set the verification flag. @@ -74,12 +78,14 @@ login. API clients cannot set the verification flag. ## Persistence model This entity-relationship diagram shows database cardinality. A user may exist -without a profile, and may own multiple projects and application sessions. +without a profile, and may own multiple projects and application sessions. A +user may also have one encrypted Matrix OAuth credential for media proxying. ```mermaid erDiagram USER ||--o| PROFILE : has USER ||--o{ AUTH_SESSION : authenticates_with + USER ||--o| MATRIX_OAUTH_CREDENTIAL : authorizes_media_for USER ||--o{ PROJECT : owns PROJECT_TYPE ||--o{ PROJECT : classifies PROJECT ||--o{ PROJECT_LABEL : tagged_with @@ -101,6 +107,7 @@ erDiagram string display_name "nullable" string bio "nullable" string avatar_url "nullable" + string matrix_avatar_mxc "private, nullable" string github_url "nullable" string website_url "nullable" datetime created_at @@ -115,6 +122,14 @@ erDiagram datetime created_at } + MATRIX_OAUTH_CREDENTIAL { + UUID id PK + UUID user_id FK, UK + string refresh_token_encrypted + datetime created_at + datetime updated_at + } + PROJECT { UUID id PK string name @@ -165,10 +180,14 @@ erDiagram 5. It calls the homeserver's `/_matrix/client/v3/account/whoami` endpoint with the access token and uses the returned Matrix ID as authoritative. 6. It fetches `/_matrix/client/v3/profile/{userId}` using that verified Matrix - ID. Its display name seeds a missing local display name; a homeserver that - disables profile lookup does not prevent login. + ID. Available display-name values seed missing local fields. A Matrix + `mxc://` avatar URI is stored privately. A homeserver that disables profile + lookup does not prevent login. 7. It resolves or creates the local user by `(issuer, subject)` and records the - verified Matrix ID on the profile. + verified Matrix ID on the profile. It encrypts and stores the OAuth refresh + token so the avatar can be fetched after the short-lived access token expires. + A deployment whose OAuth client cannot issue a refresh token still signs the + user in; only the avatar proxy is unavailable. 8. It creates an opaque Matrix Directory session and stores only its hash. 9. The browser receives the application-session cookie and is redirected to the dashboard. @@ -178,6 +197,66 @@ The default OIDC scope is `openid urn:matrix:client:api:*`. Configure backend uses the short-lived access token only for the immediate `whoami` call; it does not store that token or use the OIDC `id_token` for Matrix API access. +## Avatar thumbnails + +The browser never receives a Matrix OAuth token. When it requests +`GET /api/profiles/{userId}/avatar`, the backend decrypts the profile owner's +refresh token, obtains a short-lived access token from MAS, and streams the +authenticated Matrix `/_matrix/client/v1/media/thumbnail/...` response. The +response is publicly cacheable for one hour. This keeps the original `mxc://` +URI and all credentials out of public API responses while avoiding image +downloads or application-managed object storage. + +The proxy URL is never written to the database. `avatar_url` stores only an +image the user chose; the Matrix avatar is resolved on read, so a stored value +never has to be parsed to recover what it means. + +### Serving another server's bytes safely + +The proxied body is chosen by a Matrix account and is served from this +application's own origin, so the response is constrained on several axes: + +| Control | Value | +| --- | --- | +| Allowed media types | `image/png`, `image/jpeg`, `image/webp`, `image/gif` | +| Redirects | Not followed | +| Maximum body | 2 MiB | +| Response headers | `X-Content-Type-Options: nosniff`, a `sandbox` CSP | + +`image/svg+xml` is rejected because SVG can execute script; a same-origin SVG +avatar would be a stored cross-site scripting vector. Redirects are refused so +that a homeserver cannot direct the backend at a host nobody validated. + +### Refresh token rotation + +MAS refresh tokens are single use. The backend caches the short-lived access +token in memory and locks the credential row while redeeming a refresh token, +so concurrent avatar requests cannot present the same token twice and trigger +replay detection. + +### Credential lifetime + +The stored credential outlives a browser session on purpose: an anonymous +visitor must still be able to load a maintainer's avatar. `DELETE +/api/profile/me/matrix-avatar` is the user-facing revocation path. It clears +the stored `mxc://` URI and deletes the encrypted refresh token, after which +the application holds no Matrix credential for that account. Signing in again +restores it. + +`MATRIX_TOKEN_ENCRYPTION_KEY` is a required Fernet key for Matrix login. Set a +stable production secret, for example with: + +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Changing that key makes existing encrypted refresh tokens unreadable, so users +will need to sign in again unless a deliberate key-rotation process is added. + +The MAS OAuth client must permit both `authorization_code` and `refresh_token` +grants. The local development helper registers both; production client metadata +must do the same. + If login fails, the callback redirects to the frontend login page with a user-facing error. If OIDC is not configured, the login endpoint returns `503 Service Unavailable`. @@ -234,6 +313,13 @@ The following properties must remain true as authentication evolves: - Users are identified by the `(issuer, subject)` pair. - OIDC identity fields are never returned by public API schemas. - Raw application-session tokens are never stored in the database. +- Matrix OAuth access tokens are never stored or returned to the browser. +- Matrix refresh tokens are encrypted at rest and never returned by an API. +- Matrix-derived avatar URLs point to the local avatar proxy, not Matrix media. +- Proxied media is limited to non-scriptable image types and served with `nosniff`. +- The avatar proxy never follows a redirect away from the configured homeserver. +- A user can delete the stored Matrix credential without deleting their account. +- Avatar support never becomes a precondition for signing in. - Project ownership always comes from the authenticated session. - Authorization checks always run on the backend. - Clients cannot set `matrix_id` or `matrix_id_verified`. diff --git a/docs/docs/development.md b/docs/docs/development.md index 7a8e47c..2d25ded 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -122,7 +122,8 @@ Matrix login requires a public HTTPS callback. From the repository root, run: python scripts/dev_matrix_tunnel.py ``` -The helper registers a temporary OAuth client, starts a Cloudflare tunnel, +The helper registers a temporary OAuth client, including the `refresh_token` +grant needed for authenticated Matrix avatar thumbnails, starts a Cloudflare tunnel, updates the root `.env`, and launches Docker Compose. Keep it running while testing authentication. diff --git a/docs/docs/reference/api.md b/docs/docs/reference/api.md index 47b384f..6fcf7fd 100644 --- a/docs/docs/reference/api.md +++ b/docs/docs/reference/api.md @@ -61,7 +61,7 @@ Application errors use a JSON `detail` field: | `401 Unauthorized` | The session cookie is missing, invalid, or expired | | `404 Not Found` | The resource is missing or unavailable to its current user | | `422 Unprocessable Content` | The request body or path parameters are invalid | -| `503 Service Unavailable` | Matrix login has not been configured | +| `503 Service Unavailable` | Matrix login or media proxying has not been configured | Validation errors contain FastAPI's structured list of field errors rather than a single string. @@ -222,24 +222,71 @@ identity model and complete login flow. | Method | Path | Access | Success | Description | | --- | --- | --- | --- | --- | +| `GET` | `/api/profiles/{user_id}` | Public | `200 OK` | Get a public profile and its published projects | +| `GET` | `/api/profiles/{user_id}/avatar` | Public | `200 OK` | Stream the user's Matrix avatar thumbnail | | `PUT` | `/api/profile/me` | Authenticated | `200 OK` | Replace the current user's public profile | +| `DELETE` | `/api/profile/me/matrix-avatar` | Authenticated | `200 OK` | Stop using the Matrix avatar and delete its stored credential | + +### Update the current profile The profile body accepts these nullable fields: ```json { - "matrix_id": "@maintainer:example.org", "display_name": "Example Maintainer", "bio": "Maintains useful Matrix projects.", - "avatar_url": null, + "avatar_url": "https://example.org/me.png", "github_url": "https://github.com/example", "website_url": null } ``` Because this is a `PUT` endpoint, omitted fields are stored as `null`. -Changing `matrix_id` clears any existing verification. Clients cannot set -`matrix_id_verified`. + +`matrix_id` and `matrix_id_verified` are server-managed. They are resolved from +the homeserver during login and are rejected in a request body with `422`. + +URL fields must be absolute `http://` or `https://` URLs, matching the rule +projects use. + +### Avatar fields + +A profile carries two avatar values, and public responses expose only the +resolved one: + +| Field | Audience | Meaning | +| --- | --- | --- | +| `avatar_url` | Public | The image to display: the custom one when set, otherwise the Matrix avatar | +| `custom_avatar_url` | Owner | The image the user chose, independent of Matrix | +| `matrix_avatar_url` | Owner | Present when a Matrix avatar can be served through the proxy | + +Clearing `avatar_url` falls back to the Matrix avatar when one is connected. + +### Stream an avatar + +```http +GET /api/profiles/{user_id}/avatar +``` + +The backend uses its own stored credential, so this endpoint needs no session. +It responds with an image body, `Cache-Control: public, max-age=3600`, and +`X-Content-Type-Options: nosniff`. + +| Status | Meaning | +| --- | --- | +| `200 OK` | The thumbnail is streamed from the homeserver | +| `404 Not Found` | The user has no Matrix avatar this deployment can serve | +| `503 Service Unavailable` | Matrix media proxying is not configured | + +### Disconnect the Matrix avatar + +```http +DELETE /api/profile/me/matrix-avatar +``` + +Deletes the encrypted Matrix refresh token held for the current user and clears +the stored avatar reference, returning the updated profile. Signing in again +reconnects it. ## Related documentation diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ec4b58a..aa243ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -16,9 +16,14 @@ interface Profile { website_url: string | null } +export interface OwnProfile extends Profile { + custom_avatar_url: string | null + matrix_avatar_url: string | null +} + export interface CurrentUser { id: string - profile: Profile | null + profile: OwnProfile | null } export interface ProfileUpdate { @@ -112,12 +117,18 @@ export function getCurrentUser() { } export function updateMyProfile(profile: ProfileUpdate) { - return request('/profile/me', { + return request('/profile/me', { method: 'PUT', body: JSON.stringify(profile), }) } +export function disconnectMatrixAvatar() { + return request('/profile/me/matrix-avatar', { + method: 'DELETE', + }) +} + export function getPublicProfile(userId: string) { return request(`/profiles/${userId}`) } diff --git a/frontend/src/components/AvatarImage.vue b/frontend/src/components/AvatarImage.vue new file mode 100644 index 0000000..dddcf14 --- /dev/null +++ b/frontend/src/components/AvatarImage.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/ProjectBrowseRow.vue b/frontend/src/components/ProjectBrowseRow.vue index fe2b2df..9fdb922 100644 --- a/frontend/src/components/ProjectBrowseRow.vue +++ b/frontend/src/components/ProjectBrowseRow.vue @@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router' import type { ProjectListItem } from '../types/project' import { projectPath } from '../utils/projectRoutes' +import AvatarImage from './AvatarImage.vue' const props = defineProps<{ project: ProjectListItem @@ -15,15 +16,8 @@ const extraLabelCount = computed(() => Math.max(props.project.labels.length - 1, 0), ) -const ownerInitial = computed(() => - ( - props.project.owner?.display_name || - props.project.owner?.matrix_id || - '?' - ) - .replace(/^@/, '') - .charAt(0) - .toUpperCase(), +const ownerName = computed( + () => props.project.owner?.display_name || props.project.owner?.matrix_id, ) @@ -85,16 +79,10 @@ const ownerInitial = computed(() => text-[var(--accent-ink)] " > - - - - {{ ownerInitial }} - + :name="ownerName" + /> ), ) -const ownerInitial = computed(() => - ( - props.project.owner?.display_name || - props.project.owner?.matrix_id || - '?' - ) - .replace(/^@/, '') - .charAt(0) - .toUpperCase(), +const ownerName = computed( + () => props.project.owner?.display_name || props.project.owner?.matrix_id, ) @@ -113,16 +107,10 @@ const ownerInitial = computed(() => text-[var(--accent-ink)] " > - - - - {{ ownerInitial }} - + :name="ownerName" + /> { document.removeEventListener('pointerdown', handleClickOutside) }) -const profileInitial = computed(() => { - const name = - currentUser.value?.profile?.display_name || - currentUser.value?.profile?.matrix_id - - if (!name) { - return 'P' - } - - return name - .replace(/^@/, '') - .charAt(0) - .toUpperCase() -}) - const profileLabel = computed(() => currentUser.value?.profile?.display_name || currentUser.value?.profile?.matrix_id || @@ -167,16 +153,10 @@ watch(
- - - - {{ profileInitial }} - +
- - - - {{ profileInitial }} - +
diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue index a9ca977..c79cb66 100644 --- a/frontend/src/pages/ProfilePage.vue +++ b/frontend/src/pages/ProfilePage.vue @@ -14,12 +14,14 @@ import { } from '@heroicons/vue/24/outline' import { + disconnectMatrixAvatar, getCurrentUser, updateMyProfile, type CurrentUser, type ProfileUpdate, } from '../api/client' import { currentUser } from '../auth' +import AvatarImage from '../components/AvatarImage.vue' import MarkdownEditor from '../components/markdown/MarkdownEditor.vue' const ABOUT_YOU_MAX_LENGTH = 1024 @@ -30,6 +32,8 @@ const matrixAccountUrl = const user = ref(null) const matrixId = ref(null) +const matrixAvatarUrl = ref(null) +const disconnecting = ref(false) const loading = ref(true) const saving = ref(false) @@ -58,20 +62,15 @@ const aboutYou = computed({ }, }) -const initials = computed(() => { - const name = form.display_name?.trim() - - if (!name) { - return '?' - } +// The backend decides whether a Matrix avatar exists; the client never +// infers it from the shape of an avatar URL. +const usingMatrixAvatar = computed( + () => !form.avatar_url && matrixAvatarUrl.value !== null, +) - return name - .split(/\s+/) - .slice(0, 2) - .map((part) => part[0]) - .join('') - .toUpperCase() -}) +const previewAvatarUrl = computed( + () => form.avatar_url || matrixAvatarUrl.value, +) function normalize(value: string | null): string | null { const result = value?.trim() @@ -108,14 +107,50 @@ const showFloatingSaveBar = computed( function applyProfile(profile: CurrentUser['profile']) { matrixId.value = profile?.matrix_id ?? null + matrixAvatarUrl.value = profile?.matrix_avatar_url ?? null form.display_name = profile?.display_name ?? null form.bio = profile?.bio ?? null - form.avatar_url = profile?.avatar_url ?? null + form.avatar_url = profile?.custom_avatar_url ?? null form.github_url = profile?.github_url ?? null form.website_url = profile?.website_url ?? null } +function useMatrixAvatar() { + form.avatar_url = null +} + +async function disconnectMatrix() { + if (disconnecting.value) { + return + } + + disconnecting.value = true + error.value = '' + + try { + const profile = await disconnectMatrixAvatar() + + applyProfile(profile) + initialForm.value = snapshotForm() + + if (user.value) { + user.value.profile = profile + } + + if (currentUser.value) { + currentUser.value.profile = profile + } + } catch (err) { + error.value = + err instanceof Error + ? err.message + : 'Could not disconnect your Matrix avatar.' + } finally { + disconnecting.value = false + } +} + async function load() { loading.value = true error.value = '' @@ -256,7 +291,7 @@ onBeforeUnmount(() => {

- Manage how you appear alongside the bots you publish. + Manage how you appear alongside the projects you publish.

@@ -289,16 +324,10 @@ onBeforeUnmount(() => {
- - - - {{ initials }} - +
@@ -420,7 +449,7 @@ onBeforeUnmount(() => { - +
@@ -430,33 +459,102 @@ onBeforeUnmount(() => {

- Links + Avatar

-
-