Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 90 additions & 4 deletions docs/docs/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -67,19 +68,24 @@ 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.

## 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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand Down Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
57 changes: 52 additions & 5 deletions docs/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -112,12 +117,18 @@ export function getCurrentUser() {
}

export function updateMyProfile(profile: ProfileUpdate) {
return request<Profile>('/profile/me', {
return request<OwnProfile>('/profile/me', {
method: 'PUT',
body: JSON.stringify(profile),
})
}

export function disconnectMatrixAvatar() {
return request<OwnProfile>('/profile/me/matrix-avatar', {
method: 'DELETE',
})
}

export function getPublicProfile(userId: string) {
return request<PublicProfile>(`/profiles/${userId}`)
}
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/AvatarImage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'

import { initialsFrom } from '../utils/initials'

const props = defineProps<{
src: string | null | undefined
name?: string | null
alt?: string
}>()

const failed = ref(false)

const shouldShowImage = computed(() => Boolean(props.src) && !failed.value)
const initials = computed(() => initialsFrom(props.name))

watch(
() => props.src,
() => {
failed.value = false
},
)
</script>

<template>
<img
v-if="shouldShowImage"
:src="src ?? undefined"
:alt="alt ?? ''"
class="size-full object-cover"
@error="failed = true"
>

<slot v-else>
<span>{{ initials }}</span>
</slot>
</template>
24 changes: 6 additions & 18 deletions frontend/src/components/ProjectBrowseRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
</script>

Expand Down Expand Up @@ -85,16 +79,10 @@ const ownerInitial = computed(() =>
text-[var(--accent-ink)]
"
>
<img
v-if="project.owner.avatar_url"
<AvatarImage
:src="project.owner.avatar_url"
alt=""
class="size-full object-cover"
>

<span v-else>
{{ ownerInitial }}
</span>
:name="ownerName"
/>
</div>

<span
Expand Down
Loading