feat(chat): join users to rooms on sign-in

This commit is contained in:
Nathnael
2026-08-17 11:33:26 +00:00
parent 00bd1250ee
commit ab734aecc3
15 changed files with 366 additions and 114 deletions

View File

@@ -67,7 +67,8 @@ export class ChatProvisioningService {
} }
} }
private async currentHolders(): Promise<PositionHolder[]> { /** Every current holder in the unit, or just one person's rows when `userId` is given. */
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
return this.dataSource.query( return this.dataSource.query(
`SELECT p.key AS "positionKey", `SELECT p.key AS "positionKey",
COALESCE(p.name->>'en', p.key) AS "positionName", COALESCE(p.name->>'en', p.key) AS "positionName",
@@ -82,11 +83,53 @@ export class ChatProvisioningService {
WHERE ep.is_current = true WHERE ep.is_current = true
AND e.is_current = true AND e.is_current = true
AND o.key = $1 AND o.key = $1
AND u.key = $2`, AND u.key = $2
[ORG_KEY, UNIT_KEY], ${userId ? 'AND e.user_id = $3' : ''}`,
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
); );
} }
/**
* Put one person in their rooms right now.
*
* {@link reconcile} is nightly, so without this a new employee's first
* sign-in shows an empty client until 3AM — the SSO handoff creates their
* account but joins them to nothing. Called on every /chat/sso, so it is
* scoped to the one user (a full reconcile per click would be a room-count
* multiple of Matrix calls) and every step is get-or-create.
*
* Someone holding no current position in the unit joins nothing, by the same
* rule the reconcile uses — chat membership follows the org tree.
*/
async joinUserRooms(userId: string, displayName: string): Promise<number> {
const positions = await this.currentHolders(userId);
if (positions.length === 0) return 0;
const mxid = this.matrix.mxidFor(userId, displayName);
// The JWT login auto-registers too, but that happens after this runs and
// the admin join API 404s on an account that does not exist yet.
await this.matrix.ensureUser(mxid, displayName);
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
isSpace: true,
});
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
parentSpaceId: spaceId,
});
await this.matrix.ensureJoined(generalRoomId, mxid);
for (const position of positions) {
const roomId = await this.matrix.ensureRoom(
`dept-${position.positionKey}`,
position.positionName,
{ parentSpaceId: spaceId },
);
await this.matrix.ensureJoined(roomId, mxid);
}
return positions.length + 1;
}
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
private async syncMembership( private async syncMembership(
roomId: string, roomId: string,
@@ -99,7 +142,7 @@ export class ChatProvisioningService {
let joined = 0; let joined = 0;
for (const userId of desiredUserIds) { for (const userId of desiredUserIds) {
if (!currentSet.has(userId)) { if (!currentSet.has(userId)) {
await this.matrix.forceJoin(roomId, userId); await this.matrix.ensureJoined(roomId, userId);
joined += 1; joined += 1;
} }
} }
@@ -126,14 +169,16 @@ export class ChatProvisioningService {
parentSpaceId: spaceId, parentSpaceId: spaceId,
}); });
const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); const allUserIds = new Set(
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
);
// Accounts are otherwise only created lazily on first JWT login (see // Accounts are otherwise only created lazily on first JWT login (see
// ChatSsoService) — force-joining someone who has never clicked "Chat" // ChatSsoService) — force-joining someone who has never clicked "Chat"
// yet 404s ("User not found") without this. // yet 404s ("User not found") without this.
const seenUserIds = new Set<string>(); const seenUserIds = new Set<string>();
for (const h of holders) { for (const h of holders) {
const mxid = this.matrix.mxid(h.userId); const mxid = this.matrix.mxidFor(h.userId, h.userName);
if (seenUserIds.has(mxid)) continue; if (seenUserIds.has(mxid)) continue;
seenUserIds.add(mxid); seenUserIds.add(mxid);
await this.matrix.ensureUser(mxid, h.userName); await this.matrix.ensureUser(mxid, h.userName);
@@ -158,7 +203,7 @@ export class ChatProvisioningService {
name: h.positionName, name: h.positionName,
userIds: new Set<string>(), userIds: new Set<string>(),
}; };
entry.userIds.add(this.matrix.mxid(h.userId)); entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
byPosition.set(h.positionKey, entry); byPosition.set(h.positionKey, entry);
} }

View File

@@ -1,12 +1,13 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config'; import type { ConfigType } from '@nestjs/config';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import chatConfig from '../../config/chat.config'; import chatConfig from '../../config/chat.config';
import { MatrixClient } from './matrix.client'; import { ChatProvisioningService } from './chat-provisioning.service';
import { MatrixClient, chatLocalpart } from './matrix.client';
/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ /** Long enough for one login call, short enough to be worthless if it leaks. */
const JWT_TTL_SECONDS = 60; const JWT_TTL_SECONDS = 60;
function displayName(user: TCurrentUser): string { function displayName(user: TCurrentUser): string {
@@ -24,36 +25,67 @@ function displayName(user: TCurrentUser): string {
* *
* 1. Sign a short-lived JWT asserting this user's id (Synapse's * 1. Sign a short-lived JWT asserting this user's id (Synapse's
* org.matrix.login.jwt auto-registers the account on first use). * org.matrix.login.jwt auto-registers the account on first use).
* 2. Trade that JWT for a real Matrix access token. * 2. Trade that JWT for a real Matrix session.
* 3. Trade the access token for a one-shot login_token. * 3. Hand the caller a link to Element's sso.html shim, which writes that
* 4. Hand the caller a link to Element's sso.html shim, which seeds * session into localStorage and drops the user straight into Element.
* localStorage and forwards the token into Element's own login flow. *
* Step 3 used to mint a one-shot login_token and let Element redeem it. That
* path is capped at one request per user per minute by a limiter hardcoded in
* Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a
* spent token surfaces in Element as "Incorrect username and/or password".
* Element accepts a plaintext token out of localStorage (Lifecycle.ts
* getStoredToken/tryDecryptToken), so handing over the session we already hold
* removes both failure modes and one round-trip.
*/ */
@Injectable() @Injectable()
export class ChatSsoService { export class ChatSsoService {
private readonly logger = new Logger(ChatSsoService.name);
constructor( constructor(
@Inject(chatConfig.KEY) @Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>, private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient, private readonly matrix: MatrixClient,
private readonly provisioning: ChatProvisioningService,
) {} ) {}
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
const secret = new TextEncoder().encode(this.config.jwtSecret); const secret = new TextEncoder().encode(this.config.jwtSecret);
const jwt = await new SignJWT({ name: displayName(user) }) const name = displayName(user);
// Before the link, not after: the reconcile that fills rooms is nightly, so
// a first sign-in would otherwise open an empty client. Best-effort —
// failing to join a room is no reason to refuse someone a sign-in link.
try {
await this.provisioning.joinUserRooms(user.id, name);
} catch (err) {
this.logger.error(
`Room join on sign-in failed for ${user.id}: ${(err as Error).message}`,
);
}
// Synapse takes the localpart straight from `sub` on auto-registration, so
// this must be byte-identical to what ChatProvisioningService derives for
// the same person — otherwise SSO signs them into one account while the
// reconcile force-joins a different one into the rooms.
const jwt = await new SignJWT({ name })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id) .setSubject(chatLocalpart(user.id, name))
.setIssuer('edr-freight-api') .setIssuer('edr-freight-api')
.setAudience('matrix') .setAudience('matrix')
.setIssuedAt() .setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`) .setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(secret); .sign(secret);
const { access_token } = await this.matrix.loginWithJwt(jwt); const session = await this.matrix.loginWithJwt(jwt);
const { login_token } = await this.matrix.getLoginToken(access_token);
const url = new URL(`${this.config.webUrl}/sso.html`); // Session goes in the URL fragment, never the query: a fragment is not sent
url.searchParams.set('t', login_token); // to any server, so the token stays out of Element's access log, and
url.searchParams.set('hs', this.config.publicBaseUrl); // sso.html replaces the entry so it does not linger in history either.
return { url: url.toString() }; const params = new URLSearchParams({
hs: this.config.publicBaseUrl,
t: session.access_token,
u: session.user_id,
d: session.device_id,
});
return { url: `${this.config.webUrl}/sso.html#${params.toString()}` };
} }
} }

View File

@@ -0,0 +1,35 @@
import { chatLocalpart } from './matrix.client';
describe('chatLocalpart', () => {
it('reads from the name, not the id', () => {
expect(
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
).toBe('nati-wondi.03f5eb');
});
it('separates two people who share a name', () => {
// Both of these are real dev rows — same name, different employees.
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
expect(a).not.toBe(b);
});
it('is stable for the same person', () => {
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
});
it('still yields a usable localpart for a name that slugs to nothing', () => {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
'user.7d7982',
);
});
it('only emits characters Matrix accepts in a localpart', () => {
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
/^[a-z0-9._=\-/]+$/,
);
}
});
});

View File

@@ -14,6 +14,32 @@ import chatConfig from '../../config/chat.config';
* ChatBridgeService) — one bot/admin account covers both jobs, no separate * ChatBridgeService) — one bot/admin account covers both jobs, no separate
* bot user needed. * bot user needed.
*/ */
/**
* Localpart of a staff member's MXID: their name, plus the first 6 hex of
* their freight user id.
*
* The tail is not decoration. Names collide — 19 of the 114 users in the dev
* IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are
* two different people) — and an MXID is permanent, so a bare slug would hand
* two employees the same Matrix account and each other's rooms. The id is
* already random, so 6 hex of it separates them without a lookup or a mapping
* table, and keeps the derivation pure: ChatSsoService (which mints the JWT
* `sub`) and ChatProvisioningService (which force-joins rooms) must agree on
* this string exactly or they provision two accounts per person.
*/
export function chatLocalpart(userId: string, displayName: string): string {
const slug = displayName
// NFKD splits an accent off its letter; the non-alnum sweep below then
// folds the leftover mark into the same `-` run as the neighbouring space.
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
// Amharic-only names slug to nothing — the tail still makes it unique.
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
}
@Injectable() @Injectable()
export class MatrixClient { export class MatrixClient {
constructor( constructor(
@@ -21,11 +47,28 @@ export class MatrixClient {
private readonly config: ConfigType<typeof chatConfig>, private readonly config: ConfigType<typeof chatConfig>,
) {} ) {}
/**
* Alias localparts go in a URL path segment, so a `/` in one is fatal:
* Synapse decodes the path before routing, and `%2F` splits the request into
* a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404
* as "no such room" and ensureRoom then tries to create the same broken alias
* on every run. Position keys are `edr_freight_app/opn` shaped, so this hits
* every dept room but the handful whose key happens to be a bare word.
*/
private static aliasSafe(alias: string): string {
return alias.replace(/[^A-Za-z0-9._=-]/g, '-');
}
/** `@<localpart>:<server_name>` — the one place this format is assembled. */ /** `@<localpart>:<server_name>` — the one place this format is assembled. */
mxid(localpart: string): string { mxid(localpart: string): string {
return `@${localpart}:${this.config.serverName}`; return `@${localpart}:${this.config.serverName}`;
} }
/** The MXID of a freight user — see {@link chatLocalpart}. */
mxidFor(userId: string, displayName: string): string {
return this.mxid(chatLocalpart(userId, displayName));
}
get serverName(): string { get serverName(): string {
return this.config.serverName; return this.config.serverName;
} }
@@ -123,15 +166,13 @@ export class MatrixClient {
return Object.keys(res.joined); return Object.keys(res.joined);
} }
/** Exchange a fresh access token for a one-shot login_token (5 min TTL). */ // No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token
getLoginToken(accessToken: string): Promise<{ login_token: string }> { // is rate limited to 1 request per user per MINUTE, hardcoded in Synapse
return this.request( // (rest/client/login_token_request.py: "Ratelimit aggressively … could be
'POST', // abused by a malicious client to create many sessions") and not settable
'/_matrix/client/v1/login/get_token', // from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED.
{}, // ChatSsoService hands Element the session from loginWithJwt directly
accessToken, // instead, which needs no second call.
);
}
/** null when the alias doesn't resolve to a room yet. */ /** null when the alias doesn't resolve to a room yet. */
resolveAlias(alias: string): Promise<{ room_id: string } | null> { resolveAlias(alias: string): Promise<{ room_id: string } | null> {
@@ -180,10 +221,11 @@ export class MatrixClient {
* on every reconcile run and every bridged notification alike. * on every reconcile run and every bridged notification alike.
*/ */
async ensureRoom( async ensureRoom(
alias: string, rawAlias: string,
name: string, name: string,
opts: { isSpace?: boolean; parentSpaceId?: string } = {}, opts: { isSpace?: boolean; parentSpaceId?: string } = {},
): Promise<string> { ): Promise<string> {
const alias = MatrixClient.aliasSafe(rawAlias);
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
if (existing) return existing.room_id; if (existing) return existing.room_id;
@@ -228,6 +270,21 @@ export class MatrixClient {
); );
} }
/**
* Force-join, treating "already a member" as success. Synapse answers a
* repeat join with 403 `M_FORBIDDEN: "<user> is already in the room."`, which
* is a failure only if you assumed you knew the membership first. Callers
* that just want someone in a room (sign-in, reconcile racing itself) want
* this; the raw 403 tells them nothing they can act on.
*/
async ensureJoined(roomIdOrAlias: string, userId: string): Promise<void> {
try {
await this.forceJoin(roomIdOrAlias, userId);
} catch (err) {
if (!/already in the room/i.test((err as Error).message)) throw err;
}
}
kick(roomId: string, userId: string, reason: string): Promise<void> { kick(roomId: string, userId: string, reason: string): Promise<void> {
return this.request( return this.request(
'POST', 'POST',

View File

@@ -1,19 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { chatApi } from "./chatApi";
export const CHAT_SSO_KEY = ["chat", "sso"] as const;
/**
* The login_token this resolves to is single-use and expires in 5 minutes
* (Synapse default) — the global `staleTime: 0` (queryClient.ts) already
* means every fresh mount of the launch page refetches rather than reusing
* a possibly-spent link.
*/
export function useChatSso() {
return useQuery({
queryKey: CHAT_SSO_KEY,
queryFn: chatApi.getSsoUrl,
retry: false,
});
}

View File

@@ -1,17 +1,44 @@
import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core";
import { MessageSquare, TriangleAlert } from "lucide-react"; import { MessageSquare, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { useChatSso } from "@/features/chat/useChatSso"; import { chatApi } from "@/features/chat/chatApi";
/** /**
* Chat itself lives at chat.edr.et (Element), not in this app — this page's * Chat itself lives at chat.edr.et (Element), not in this app — this page's
* only job is a fresh one-click sign-in link into it. A real `<a>` (not * only job is a one-click sign-in link into it. No iframe: Element's own CSP
* `window.open()` in a click handler) so the browser never treats it as a * refuses to be framed.
* blocked popup, and no iframe: Element's own CSP refuses to be framed. *
* The link is minted per click, never on mount and never cached: Synapse's
* login_token is single-use and expires in 5 minutes, and Element reports a
* spent one as "Incorrect username and/or password". A held-onto url is
* therefore wrong on the second click, on a remount served from cache, and on
* any click more than 5 minutes after the page loaded.
*/ */
export default function ChatLaunchPage() { export default function ChatLaunchPage() {
const { data: url, isLoading, isError, refetch } = useChatSso(); const [state, setState] = useState<"idle" | "loading" | "error">("idle");
const open = async () => {
// Opened before the await so it still counts as the user's click — a
// window.open() after it is treated as a popup and blocked.
//
// No "noopener" in the features: passing it makes window.open return null,
// which would leave this blank tab orphaned and send Element into the
// current tab instead. Clearing .opener on the handle does the same job.
const tab = window.open("", "_blank");
if (tab) tab.opener = null;
setState("loading");
try {
const url = await chatApi.getSsoUrl();
if (tab) tab.location.replace(url);
else window.location.assign(url); // popup blocked — go in this tab
setState("idle");
} catch {
tab?.close();
setState("error");
}
};
return ( return (
<PageContainer> <PageContainer>
@@ -19,41 +46,30 @@ export default function ChatLaunchPage() {
<Card withBorder radius="md" p="xl"> <Card withBorder radius="md" p="xl">
<Center> <Center>
<Stack align="center" gap="md" py="xl"> <Stack align="center" gap="md" py="xl">
{isLoading && <Loader />} {state === "error" && (
<Alert
{isError && ( icon={<TriangleAlert size={18} />}
<Stack align="center" gap="sm"> color="red"
<Alert title="Couldn't get a sign-in link"
icon={<TriangleAlert size={18} />} variant="light"
color="red" >
title="Couldn't get a sign-in link" Something went wrong reaching chat. Try again.
variant="light" </Alert>
>
Something went wrong reaching chat. Try again.
</Alert>
<Button variant="light" onClick={() => refetch()}>
Retry
</Button>
</Stack>
)} )}
{url && ( <Stack align="center" gap="sm">
<Stack align="center" gap="sm"> <MessageSquare size={40} strokeWidth={1.5} />
<MessageSquare size={40} strokeWidth={1.5} /> <Text c="dimmed" ta="center" maw={360}>
<Text c="dimmed" ta="center" maw={360}> Opens EDR Chat in a new tab, already signed in as you.
Opens EDR Chat in a new tab, already signed in as you. </Text>
</Text> <Button
<Button onClick={open}
component="a" loading={state === "loading"}
href={url} leftSection={<MessageSquare size={16} />}
target="_blank" >
rel="noopener noreferrer" Open EDR Chat
leftSection={<MessageSquare size={16} />} </Button>
> </Stack>
Open EDR Chat
</Button>
</Stack>
)}
</Stack> </Stack>
</Center> </Center>
</Card> </Card>

View File

@@ -140,6 +140,10 @@ services:
context: infrastructure/matrix/element context: infrastructure/matrix/element
ports: ports:
- "${ELEMENT_WEB_PORT:-8080}:80" - "${ELEMENT_WEB_PORT:-8080}:80"
# config.json is rendered at container start, not baked — one image serves
# dev, staging and prod. See infrastructure/matrix/element/.env.example.
env_file:
- infrastructure/matrix/element/.env
restart: always restart: always
volumes: volumes:

View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Renders /app/config.json from config.json.tmpl at container start.
#
# The homeserver URL and server_name differ per environment, and config.json is
# read by the browser rather than the build, so baking it into the image would
# mean one image per environment. Dropped into /docker-entrypoint.d, which the
# upstream nginx entrypoint runs (in lexical order) before starting nginx —
# no ENTRYPOINT override, so the image's own startup work still happens.
set -eu
: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}"
: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}"
: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}"
envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \
< /app/config.json.tmpl > /app/config.json
echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})"

View File

@@ -5,5 +5,19 @@
# Pin the tag; never float on `latest`. # Pin the tag; never float on `latest`.
FROM ghcr.io/element-hq/element-web:v1.11.108 FROM ghcr.io/element-hq/element-web:v1.11.108
COPY config.json /app/config.json COPY config.json.tmpl /app/config.json.tmpl
COPY sso.html /app/sso.html COPY sso.html /app/sso.html
# Replaces the upstream manifest, which names the app "Element" and advertises
# the Play/App Store builds under related_applications. Those apps cannot log
# in here — this deployment has no password login and no SSO provider, only the
# JWT handoff from freight-api — so pointing staff at them is a dead end.
COPY manifest.json /app/manifest.json
COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh
# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup
# hook could not write the rendered config without this.
USER root
RUN chmod +x /docker-entrypoint.d/40-element-config.sh \
&& touch /app/config.json \
&& chown nginx:nginx /app/config.json
USER nginx

View File

@@ -1,16 +1,17 @@
{ {
"default_server_config": { "default_server_config": {
"m.homeserver": { "m.homeserver": {
"base_url": "https://matrix.edr.et", "base_url": "${MATRIX_PUBLIC_BASEURL}",
"server_name": "matrix.edr.et" "server_name": "${MATRIX_SERVER_NAME}"
} }
}, },
"brand": "EDR Chat", "brand": "EDR Chat",
"permalink_prefix": "https://chat.edr.et", "permalink_prefix": "${ELEMENT_PUBLIC_URL}",
"disable_guests": true, "disable_guests": true,
"disable_3pid_login": true, "disable_3pid_login": true,
"disable_custom_urls": true, "disable_custom_urls": true,
"default_theme": "light", "default_theme": "light",
"mobile_guide_toast": false,
"settingDefaults": { "settingDefaults": {
"UIFeature.registration": false "UIFeature.registration": false
} }

View File

@@ -0,0 +1,12 @@
{
"name": "EDR Chat",
"short_name": "EDR Chat",
"display": "standalone",
"theme_color": "#0dbd8b",
"start_url": "index.html",
"icons": [
{ "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" },
{ "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" },
{ "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" }
]
}

View File

@@ -1,16 +1,23 @@
<!doctype html> <!doctype html>
<!-- <!--
Element only honours a `?loginToken=` on `/` if `mx_sso_hs_url` is already Session handoff from freight-api into Element.
in localStorage (element-web apps/web/src/Lifecycle.ts attemptTokenLogin,
key defined in apps/web/src/BasePlatform.ts). Normally that key is written
by Element itself at the start of an SSO redirect; freight-api's SSO
handoff skips that redirect (it already knows the homeserver), so this
page seeds the key by hand and forwards straight to the login-token URL.
freight-api's chat-sso.service.ts links here as freight-api's chat-sso.service.ts links here as
https://chat.edr.et/sso.html?t=<login_token>&hs=<homeserver base_url>. https://chat.edr.et/sso.html#hs=<homeserver>&t=<access_token>&u=<user_id>&d=<device_id>
`hs` is passed rather than hardcoded so this file doesn't need to change if — a fragment, not a query, so the token is never sent to a server and never
MATRIX_PUBLIC_BASEURL ever does. lands in an access log. location.replace() below drops this URL from history
as well, so the token does not survive the redirect.
The keys written here are the ones Element reads on startup
(element-web src/Lifecycle.ts getStoredSessionVars/getStoredToken): the token
is looked up in IndexedDB first and falls back to localStorage, which Element
then migrates into IndexedDB itself. A plaintext token is accepted —
tryDecryptToken returns a string token as-is, and only decrypts when it finds
an encrypted payload.
This replaced a ?loginToken= handoff: POST /_matrix/client/v1/login/get_token
is capped at one call per user per minute by a limiter hardcoded in Synapse,
so clicking Chat twice in a minute failed.
--> -->
<html lang="en"> <html lang="en">
<head> <head>
@@ -19,17 +26,23 @@
</head> </head>
<body> <body>
<script> <script>
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.hash.slice(1));
var token = params.get("t");
var homeserver = params.get("hs"); var homeserver = params.get("hs");
if (token && homeserver) { var token = params.get("t");
localStorage.setItem("mx_sso_hs_url", homeserver); var userId = params.get("u");
window.location.replace( var deviceId = params.get("d");
"/?loginToken=" + encodeURIComponent(token),
); if (homeserver && token && userId && deviceId) {
localStorage.setItem("mx_hs_url", homeserver);
localStorage.setItem("mx_user_id", userId);
localStorage.setItem("mx_device_id", deviceId);
localStorage.setItem("mx_access_token", token);
localStorage.setItem("mx_has_access_token", "true");
localStorage.setItem("mx_is_guest", "false");
window.location.replace("/");
} else { } else {
document.body.textContent = document.body.textContent =
"Missing sign-in token. Go back to the EDR backoffice and click Chat again."; "Missing sign-in details. Go back to the EDR backoffice and click Chat again.";
} }
</script> </script>
</body> </body>

View File

@@ -42,6 +42,24 @@ federation_domain_whitelist: []
enable_registration: false enable_registration: false
encryption_enabled_by_default_for_room_type: "off" encryption_enabled_by_default_for_room_type: "off"
# Turning rooms' encryption off above is not enough on its own: Element still
# bootstraps cross-signing on a user's first login, and from then on gates
# EVERY later login behind "Verify this device" (MatrixChat: crossSigningIsSetUp
# -> Views.COMPLETE_SECURITY). Nobody on this deployment can clear that gate —
# each SSO click is a brand-new device, so there is never a second verified
# device to accept the request, and resetting the identity needs UIA, which
# password_config.enabled: false makes impossible.
#
# This tells Element encryption is off here, so it skips the bootstrap
# (shouldSkipSetupEncryption) and the gate is never armed. Only helps accounts
# that have no cross-signing keys yet — anyone already bootstrapped keeps
# hitting the gate until their keys are cleared.
extra_well_known_client_content:
io.element.e2ee:
default: false
force_disable: true
secure_backup_required: false
# Employees authenticate via freight-api's SSO handoff, never a Matrix # Employees authenticate via freight-api's SSO handoff, never a Matrix
# password prompt. This is the entire auth story for this deployment. # password prompt. This is the entire auth story for this deployment.
password_config: password_config:

View File

@@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=(
["passenger_portal"]="apps/edr-passenger-web/portal/.env" ["passenger_portal"]="apps/edr-passenger-web/portal/.env"
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env" ["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment_api"]="apps/edr-payment-api/.env" ["payment_api"]="apps/edr-payment-api/.env"
["synapse"]="infrastructure/matrix/synapse/.env"
# element_web holds no secrets, but its config.json is rendered at container
# start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL
# (see infrastructure/matrix/element), so it needs an env file like the rest —
# plus the PORT line every service env is required to carry.
["element_web"]="infrastructure/matrix/element/.env"
) )
for service in "$@"; do for service in "$@"; do

View File

@@ -32,9 +32,9 @@ declare -A SERVICE_ENV_TARGET=(
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment-api"]="apps/edr-payment-api/.env" ["payment-api"]="apps/edr-payment-api/.env"
["synapse"]="infrastructure/matrix/synapse/.env" ["synapse"]="infrastructure/matrix/synapse/.env"
# element-web has no runtime secrets (its config.json is baked into the # element-web holds no secrets, but its config.json is rendered at container
# image), but the sync step still runs unconditionally per service and # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL,
# needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. # and the sync step needs a PORT= line to compute ELEMENT_WEB_PORT anyway.
["element-web"]="infrastructure/matrix/element/.env" ["element-web"]="infrastructure/matrix/element/.env"
) )