mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
92 lines
3.7 KiB
TypeScript
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()}` };
|
|
}
|
|
}
|