Files
edr-platform/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
2026-08-17 12:53:08 +00:00

92 lines
3.7 KiB
TypeScript

import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { SignJWT } from 'jose';
import chatConfig from '../../config/chat.config';
import { ChatProvisioningService } from './chat-provisioning.service';
import { MatrixClient, chatLocalpart } from './matrix.client';
/** Long enough for one login call, short enough to be worthless if it leaks. */
const JWT_TTL_SECONDS = 60;
function displayName(user: TCurrentUser): string {
return (
user.name?.en ||
Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) ||
user.username ||
user.email
);
}
/**
* The SSO handoff: turn an already-authenticated freight session into a
* one-click Element sign-in link, with no second password anywhere.
*
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
* org.matrix.login.jwt auto-registers the account on first use).
* 2. Trade that JWT for a real Matrix session.
* 3. Hand the caller a link to Element's sso.html shim, which writes that
* session into localStorage and drops the user straight into Element.
*
* 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()
export class ChatSsoService {
private readonly logger = new Logger(ChatSsoService.name);
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
private readonly provisioning: ChatProvisioningService,
) {}
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
const secret = new TextEncoder().encode(this.config.jwtSecret);
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' })
.setSubject(chatLocalpart(user.id, name))
.setIssuer('edr-freight-api')
.setAudience('matrix')
.setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(secret);
const session = await this.matrix.loginWithJwt(jwt);
// Session goes in the URL fragment, never the query: a fragment is not sent
// to any server, so the token stays out of Element's access log, and
// sso.html replaces the entry so it does not linger in history either.
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()}` };
}
}