Compare commits

...

8 Commits

Author SHA1 Message Date
jared efcee88f05 docs(test): add OIDC/MSC3861 test section + local MAS dev loop
CI / Build & Quality Checks (push) Successful in 13m42s
CI / Trigger Desktop Build (push) Successful in 33s
LOTUS_TESTING.md section N (N1-N6): OIDC login flow, session-persist-on-reload,
token refresh, logout revocation, account-management link, and the non-OIDC
regression check. Backed by dev/oidc-test/ — a runnable local Matrix
Authentication Service + Synapse(msc3861) loop (compose skeleton, the Synapse
experimental_features delta, and the public/config.json override) so the flow
can be verified without a mozilla.org tester.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:04:24 -04:00
jared 0b307037e0 docs(todo): P4-6 OIDC client-side built, awaiting live verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:40 -04:00
jared 67bd05fc96 feat(auth): OIDC phase 4/5/6 — token refresh, logout revocation, account link
- initMatrix.ts: import the shared Session type; when a session has a refresh
  token + oidc metadata, wire a LotusOidcTokenRefresher via createClient's
  refreshToken + tokenRefreshFunction (reactive 401 refresh). Rust crypto is
  unaffected (still keyed on userId/deviceId).
- client/oidcTokenRefresher.ts: OidcTokenRefresher subclass that persists rotated
  tokens back to the fallback session.
- client/oidcLogout.ts + logoutClient: best-effort revoke access+refresh tokens at
  the issuer's revocation_endpoint on logout (tolerant of failure).
- settings/account/OidcManageAccount.tsx: MSC2965 "Manage account" deep-link,
  shown only when authMetadata is present (OIDC servers); mirrors OtherDevices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:12:13 -04:00
jared dd6b0bccb3 feat(auth): OIDC phase 3 — authorization-code callback route
- oidc/OidcCallback.tsx: standalone page that exchanges code+state via
  completeAuthorizationCodeGrant (SDK validates state = CSRF), derives
  user_id/device_id from the new access token via whoami(), persists the OIDC
  session (refresh token + expiry + issuer/clientId/redirectUri/idTokenClaims),
  then full-page-reloads at the app root. Minimal UI (no Overlay/portal) so it
  needs no app providers.
- App.tsx: short-circuit — render OidcCallback before the RouterProvider when the
  path is the OIDC callback (redirect_uris can't contain a fragment, so it must
  live outside the hash router). The nginx SPA catch-all already serves index.html
  for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:05:22 -04:00
jared a50d3e7ca7 feat(auth): OIDC phase 2 — login initiation (discover/register/authorize)
- oidc/oidcState.ts (pure, +3 tests): dynamic-registration cache (by issuer +
  redirectUri, corrupt-tolerant) and parseOidcCallbackParams (success/error/invalid).
- oidc/oidcLoginUtil.ts: getOrRegisterClientId (cache + registerOidcClient) and
  startOidcLogin (discoverAndValidateOIDCIssuerWellKnown -> generateOidcAuthorization
  Url -> redirect; invalidates the cache on failure). redirectUri is the
  deterministic getOidcCallbackUrl(), and the SDK returns clientId/issuer on
  callback, so no hand-rolled transient state is needed.
- login/OidcLogin.tsx: native-OIDC button mirroring SSOLogin + TokenLogin async/error.
- login/Login.tsx: issuer-gated — when discovery advertises an issuer, render
  OidcLogin and suppress password/legacy-SSO; non-OIDC servers unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:01:35 -04:00
jared d3d2f9a448 feat(auth): OIDC phase 4a — session persistence for refresh/expiry/oidc metadata
setFallbackSession gains an optional `extra` arg (password call sites unchanged)
persisting cinny_refresh_token, cinny_expires_at (absolute), and
cinny_oidc_{issuer,client_id,redirect_uri,id_token_claims}. getFallbackSession
reads them back (expiry as remaining lifetime); removeFallbackSession + re-save
clear stale OIDC keys. Session type gains `oidc?: OidcSessionMeta`. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:55:30 -04:00
jared 98ad5674a8 feat(auth): OIDC phase 0+1 — discovery, flow detection, client config
Toward MSC3861/MSC2965 next-gen-auth login (P4-6), client-only.
- cs-api.ts: type the stable `m.authentication` well-known key + getOidcIssuer()
  (stable preferred over the unstable msc2965 key; {} for non-OIDC servers).
- useParsedLoginFlows.ts: getOidcCompatibilityFlag() (MSC3824 oauth_aware_preferred
  / delegated_oidc_compatibility) as a secondary OIDC hint.
- New pages/auth/oidc/oidcConfig.ts: dynamic-registration client metadata + the
  non-hash callback URL (redirect_uris can't contain a fragment).
- paths.ts: OIDC_CALLBACK_PATH.
- 8 unit tests for the pure helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:51:23 -04:00
jared 30d0331174 fix(ui): isMacOS always returned false on Macs + plugin-logic tests (+49)
Coverage work found a 3rd real bug: isMacOS() compared os.name against the
legacy 'Mac OS' string, but ua-parser-js v2 reports 'macOS' — so it was dead,
and Mac users saw "Ctrl + k" instead of "⌘ + k" in the editor toolbar, search,
and settings shortcut hints. Now accepts both 'macOS' and 'Mac OS'.

Suites (via subagent, verified): via-servers (10 — power/popularity server
selection), bad-words (9), syntaxHighlight tokenize (14), plugins/utils
getEmoticonSearchStr (5), imageCompression formatFileSize/isCompressible (5),
user-agent (6, now asserting the fixed behavior).

Full suite now 501 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:58:06 -04:00
32 changed files with 1461 additions and 17 deletions
+44
View File
@@ -410,6 +410,50 @@ Settings → Appearance → theme picker → try each of the 5 new themes.
--- ---
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
delta + a `config.json` override) OR against **mozilla.org** with a real account.
### N1. OIDC login flow (the core test) — needs a MAS homeserver
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
### N2. Session persists across reload (token storage)
After N1, hard-refresh the page.
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
### N3. Token refresh (long-lived session)
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
### N4. Logout revokes at the issuer
Log out from Settings.
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
### N5. Account-management deep-link
Settings → Account.
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
### N6. Non-OIDC regression — password login unchanged
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
---
## Priority if you're short on time ## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce. 1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
+13 -7
View File
@@ -241,14 +241,20 @@ Features:
--- ---
### [ ] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) (EXTREME COMPLEXITY, LOW PRIORITY) ### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
**Spec:** MSC3861, merged Matrix spec v1.15. Uses Matrix Authentication Service (MAS). **Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
**Context:** ~80% of homeserver users have LLDAP/Authelia/SSO accounts. SSO is currently enabled on `matrix.lotusguild.org` but accounts are not yet linked. This would allow users to log in via their SSO credentials. **Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity. **Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
**EXTREME COMPLEXITY** — requires: MAS deployment/configuration on the homeserver, significant auth flow changes in the client, token refresh handling, session management overhaul.
**[SERVER CHECK]** — Before any client work, audit whether MAS is already deployed on `compute-storage-01`. Check: `pct exec 151 -- systemctl status matrix-authentication-service` or similar. - Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
**Complexity:** Extreme. Multi-sprint project. Plan separately. - Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
**To enable the mozilla.org test:** add to `matrix/cinny/config.json` homeserverList `"mozilla.org"`, and to the nginx CSP `connect-src`/`img-src`: `https://mozilla.org https://mozilla.modular.im https://chat.mozilla.org https://vector.im`.
--- ---
+112
View File
@@ -0,0 +1,112 @@
# Local OIDC / next-gen-auth (MSC3861) test loop
The Lotus client gained MSC3861/MSC2965 OIDC login (P4-6). lotusguild's own
homeserver is **not** MSC3861, so to exercise the flow without a mozilla.org
tester you need a local homeserver that delegates auth to a **Matrix
Authentication Service (MAS)**. This is the dev loop.
> Status: the Lotus-client side is unit-tested + gate-green; this server loop is
> the manual end-to-end check. It hasn't been run in CI (no container runtime
> there), so treat version pins as a starting point and bump as needed.
## 1. Stand up MAS + Synapse
The simplest path is the **upstream MAS docker-compose quickstart** — it's
maintained and handles key generation + the database:
<https://element-hq.github.io/matrix-authentication-service/setup/installation.html>
(`docker compose` section). Use it to get MAS + Synapse + Postgres running, then
apply the two Lotus-specific deltas below.
A minimal `compose.yaml` skeleton (generate MAS keys first — do **not** hand-write them):
```yaml
services:
postgres:
image: postgres:16
environment: { POSTGRES_USER: synapse, POSTGRES_PASSWORD: pw, POSTGRES_DB: synapse }
mas:
image: ghcr.io/element-hq/matrix-authentication-service:latest
command: server
ports: ['8090:8080'] # MAS issuer on http://localhost:8090
volumes: ['./mas:/data']
# First run once: `docker compose run --rm mas config generate -o /data/config.yaml`
# then edit /data/mas/config.yaml (see §1a) before `up`.
synapse:
image: ghcr.io/element-hq/synapse:latest
ports: ['8008:8008'] # client/federation API
volumes: ['./synapse:/data']
depends_on: [postgres, mas]
```
### 1a. MAS `config.yaml` — the parts that matter
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
```yaml
http:
public_base: http://localhost:8090/
issuer: http://localhost:8090/
database:
uri: postgresql://synapse:pw@postgres/synapse
matrix:
homeserver: localhost # the server_name
endpoint: http://synapse:8008/
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
clients:
- client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
passwords: # so you can create a local test account in the MAS UI
enabled: true
```
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
See `synapse-msc3861.yaml` in this folder; the key block is:
```yaml
experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
account_management_url: "http://localhost:8090/account"
```
Create a test user via the MAS UI (`http://localhost:8090/`) or
`docker compose exec mas mas-cli manage register-user`.
Sanity check discovery (the client relies on this):
```bash
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
```
## 2. Point the Lotus dev client at it
Run the client: `npm start` (vite dev). Override `public/config.json` so the
local server is selectable and custom servers are allowed:
```json
{
"defaultHomeserver": 0,
"homeserverList": ["localhost:8008"],
"allowCustomHomeservers": true,
"hashRouter": { "enabled": false, "basename": "/" }
}
```
Dynamic client registration handles the redirect URI automatically — it's
`<vite-origin>/auth/oidc/callback` (e.g. `http://localhost:5173/auth/oidc/callback`),
and MAS allows `http://localhost` redirects in dev.
## 3. Run the checklist
See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
(login redirect, callback, session-persist-on-reload, token refresh, logout
revocation, account-management link, and the non-OIDC-regression check).
## Files here
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
- `config.local.json` — the Lotus `public/config.json` override.
+7
View File
@@ -0,0 +1,7 @@
{
"defaultHomeserver": 0,
"homeserverList": ["localhost:8008"],
"allowCustomHomeservers": true,
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
"hashRouter": { "enabled": false, "basename": "/" }
}
+16
View File
@@ -0,0 +1,16 @@
# Synapse experimental-features delta to delegate auth to a local MAS (MSC3861).
# Merge this into your test homeserver.yaml. The client_secret + admin_token MUST
# match the MAS config (clients[].client_secret and matrix.secret respectively).
experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
account_management_url: "http://localhost:8090/account"
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
# Lotus client's getOidcIssuer() reads to switch into the OIDC flow.
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getOidcIssuer, AutoDiscoveryInfo } from './cs-api';
const info = (extra: Record<string, unknown>): AutoDiscoveryInfo =>
({ 'm.homeserver': { base_url: 'https://hs' }, ...extra }) as AutoDiscoveryInfo;
test('getOidcIssuer reads the stable m.authentication key', () => {
assert.deepEqual(
getOidcIssuer(info({ 'm.authentication': { issuer: 'https://i', account: 'https://a' } })),
{ issuer: 'https://i', account: 'https://a' },
);
});
test('getOidcIssuer falls back to the unstable msc2965 key', () => {
assert.deepEqual(
getOidcIssuer(info({ 'org.matrix.msc2965.authentication': { issuer: 'https://u' } })),
{ issuer: 'https://u', account: undefined },
);
});
test('getOidcIssuer prefers stable over unstable when both present', () => {
assert.equal(
getOidcIssuer(
info({
'm.authentication': { issuer: 'https://stable' },
'org.matrix.msc2965.authentication': { issuer: 'https://unstable' },
}),
).issuer,
'https://stable',
);
});
test('getOidcIssuer returns {} for non-OIDC servers', () => {
assert.deepEqual(getOidcIssuer(info({})), {});
assert.deepEqual(getOidcIssuer(info({ 'm.authentication': {} })), {}); // present but no issuer
});
+25
View File
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: { 'm.identity_server'?: {
base_url: string; base_url: string;
}; };
// v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that
// delegate to a Matrix Authentication Service (e.g. mozilla.org). The
// `org.matrix.msc2965.authentication` key below is the unstable predecessor.
'm.authentication'?: {
issuer?: string;
account?: string;
};
'org.matrix.msc2965.authentication'?: { 'org.matrix.msc2965.authentication'?: {
account?: string; account?: string;
issuer?: string; issuer?: string;
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
]; ];
}; };
/**
* Resolve the OIDC issuer (and account-management URL) advertised by a homeserver
* in its `.well-known/matrix/client`, preferring the v1.15 stable
* `m.authentication` key over the unstable `org.matrix.msc2965.authentication`.
* Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org).
*/
export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => {
const stable = info['m.authentication'];
if (stable && typeof stable.issuer === 'string') {
return { issuer: stable.issuer, account: stable.account };
}
const unstable = info['org.matrix.msc2965.authentication'];
if (unstable && typeof unstable.issuer === 'string') {
return { issuer: unstable.issuer, account: unstable.account };
}
return {};
};
export const autoDiscovery = async ( export const autoDiscovery = async (
request: typeof fetch, request: typeof fetch,
server: string, server: string,
@@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile'; import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo'; import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList'; import { IgnoredUserList } from './IgnoredUserList';
import { OidcManageAccount } from './OidcManageAccount';
type AccountProps = { type AccountProps = {
requestClose: () => void; requestClose: () => void;
@@ -32,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<Profile /> <Profile />
<MatrixId /> <MatrixId />
<OidcManageAccount />
<ContactInformation /> <ContactInformation />
<IgnoredUserList /> <IgnoredUserList />
</Box> </Box>
@@ -0,0 +1,49 @@
import React, { useCallback } from 'react';
import { Box, Chip, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { withSearchParam } from '../../../pages/pathUtils';
/**
* On OIDC/next-gen-auth servers, profile/password/sessions are managed by the
* authentication service — surface a deep-link to its account page (MSC2965
* account-management URL). Renders nothing on password/legacy-SSO servers, where
* `useAuthMetadata()` is undefined.
*/
export function OidcManageAccount() {
const authMetadata = useAuthMetadata();
const accountManagementActions = useAccountManagementActions();
const open = useCallback(() => {
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
if (!authUrl) return;
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
}, [authMetadata, accountManagementActions]);
if (!authMetadata) return null;
return (
<Box direction="Column" gap="100">
<Text size="L400">Account Management</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Manage account"
description="Your profile, password, and sessions are managed by your single sign-on provider."
after={
<Chip variant="Secondary" radii="Pill" onClick={open}>
<Text size="T200">Open</Text>
</Chip>
}
/>
</SequenceCard>
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
import {
getOidcCompatibilityFlag,
getSSOFlow,
getPasswordFlow,
getTokenFlow,
} from './useParsedLoginFlows';
const flows = (...f: Record<string, unknown>[]): LoginFlow[] => f as unknown as LoginFlow[];
test('flow getters pick the right flow', () => {
const f = flows({ type: 'm.login.password' }, { type: 'm.login.sso' }, { type: 'm.login.token' });
assert.equal(getPasswordFlow(f)?.type, 'm.login.password');
assert.equal(getSSOFlow(f)?.type, 'm.login.sso');
assert.equal(getTokenFlow(f)?.type, 'm.login.token');
assert.equal(getSSOFlow(flows({ type: 'm.login.cas' }))?.type, 'm.login.cas');
});
test('getOidcCompatibilityFlag detects the stable flag name', () => {
assert.equal(
getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: true })),
true,
);
});
test('getOidcCompatibilityFlag detects the msc3824 alt name', () => {
assert.equal(
getOidcCompatibilityFlag(
flows({ type: 'm.login.sso', 'org.matrix.msc3824.delegated_oidc_compatibility': true }),
),
true,
);
});
test('getOidcCompatibilityFlag is false without sso or without the flag', () => {
assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.password' })), false);
assert.equal(getOidcCompatibilityFlag(flows({ type: 'm.login.sso' })), false);
// non-true values do not count
assert.equal(
getOidcCompatibilityFlag(flows({ type: 'm.login.sso', oauth_aware_preferred: 'yes' })),
false,
);
});
+23 -1
View File
@@ -1,5 +1,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth'; import {
ILoginFlow,
IPasswordFlow,
ISSOFlow,
LoginFlow,
OAUTH_AWARE_PREFERRED_FLOW_FIELD,
} from 'matrix-js-sdk/lib/@types/auth';
export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined => export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined =>
loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as
@@ -13,6 +19,22 @@ export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
type: 'm.login.token'; type: 'm.login.token';
}; };
/**
* MSC3824: a server may advertise `m.login.sso` while signalling (via the
* `oauth_aware_preferred` / `org.matrix.msc3824.delegated_oidc_compatibility`
* flow field) that it actually prefers native OIDC. This is a *secondary* hint —
* the authoritative signal for next-gen auth is an issuer in `.well-known`
* discovery (see `getOidcIssuer` in cs-api.ts).
*/
export const getOidcCompatibilityFlag = (loginFlows: LoginFlow[]): boolean => {
const sso = getSSOFlow(loginFlows) as (ISSOFlow & Record<string, unknown>) | undefined;
if (!sso) return false;
return (
sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name] === true ||
sso[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName] === true
);
};
export type ParsedLoginFlows = { export type ParsedLoginFlows = {
password?: LoginFlow; password?: LoginFlow;
token?: LoginFlow; token?: LoginFlow;
+10
View File
@@ -28,6 +28,8 @@ import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect'; import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex'; import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
const FONT_MAP: Record<string, string> = { const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -111,6 +113,14 @@ function App() {
const portalContainer = document.getElementById('portalContainer') ?? undefined; const portalContainer = document.getElementById('portalContainer') ?? undefined;
// OIDC/next-gen-auth callback is a real (non-hash) path: OAuth redirect_uris
// can't contain a fragment, so it must be handled OUTSIDE the router. Render
// the standalone callback page before the RouterProvider mounts. It needs no
// app providers (it only touches the SDK + localStorage).
if (window.location.pathname.endsWith(OIDC_CALLBACK_PATH)) {
return <OidcCallback />;
}
return ( return (
<ErrorBoundary <ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => ( fallbackRender={({ error, resetErrorBoundary }) => (
+27
View File
@@ -8,6 +8,9 @@ import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
import { PasswordLoginForm } from './PasswordLoginForm'; import { PasswordLoginForm } from './PasswordLoginForm';
import { SSOLogin } from '../SSOLogin'; import { SSOLogin } from '../SSOLogin';
import { TokenLogin } from './TokenLogin'; import { TokenLogin } from './TokenLogin';
import { OidcLogin } from './OidcLogin';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { getOidcIssuer } from '../../../cs-api';
import { OrDivider } from '../OrDivider'; import { OrDivider } from '../OrDivider';
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils'; import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
@@ -36,6 +39,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
export function Login() { export function Login() {
const server = useAuthServer(); const server = useAuthServer();
const discovery = useAutoDiscoveryInfo();
const { hashRouter } = useClientConfig(); const { hashRouter } = useClientConfig();
const { loginFlows } = useAuthFlows(); const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -54,6 +58,29 @@ export function Login() {
const parsedFlows = useParsedLoginFlows(loginFlows.flows); const parsedFlows = useParsedLoginFlows(loginFlows.flows);
// Next-gen auth (MSC3861/MSC2965): when the server advertises an OIDC issuer in
// its `.well-known` discovery, use the native OIDC flow and suppress the
// password / legacy-SSO forms (those servers, e.g. MAS, reject `m.login.password`).
const oidc = getOidcIssuer(discovery);
if (oidc.issuer) {
return (
<Box direction="Column" gap="500">
<Text as="h1" size="H2" priority="400">
Login
</Text>
<OidcLogin
issuer={oidc.issuer}
homeserverBaseUrl={discovery['m.homeserver'].base_url}
serverName={server}
/>
<span data-spacing-node />
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
</Box>
);
}
return ( return (
<Box direction="Column" gap="500"> <Box direction="Column" gap="500">
<Text as="h1" size="H2" priority="400"> <Text as="h1" size="H2" priority="400">
+48
View File
@@ -0,0 +1,48 @@
import { Box, Button, Spinner, Text, color } from 'folds';
import React, { useCallback } from 'react';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { startOidcLogin } from '../oidc/oidcLoginUtil';
type OidcLoginProps = {
issuer: string;
homeserverBaseUrl: string;
serverName: string;
};
/**
* Native next-gen-auth (MSC3861/MSC2965) login button. Discovers + registers
* with the homeserver's OIDC provider and redirects to it. Mirrors `SSOLogin`'s
* single-button styling.
*/
export function OidcLogin({ issuer, homeserverBaseUrl, serverName }: OidcLoginProps) {
const [state, startLogin] = useAsyncCallback<void, unknown, Parameters<typeof startOidcLogin>>(
useCallback(startOidcLogin, []),
);
// Loading = discovering/registering; Success = redirect issued (page is leaving) —
// keep the button busy in both cases.
const ongoing = state.status === AsyncStatus.Loading || state.status === AsyncStatus.Success;
return (
<Box direction="Column" gap="300">
{state.status === AsyncStatus.Error && (
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
Failed to start single sign-on. Please try again.
</Text>
)}
<Button
style={{ width: '100%' }}
size="500"
variant="Secondary"
fill="Soft"
outlined
onClick={() => startLogin(issuer, homeserverBaseUrl)}
disabled={ongoing}
before={ongoing ? <Spinner size="200" variant="Secondary" /> : undefined}
>
<Text align="Center" size="B500" truncate>
{`Continue with ${serverName}`}
</Text>
</Button>
</Box>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Box, Button, Icon, Icons, Spinner, Text, color, config } from 'folds';
import React, { useCallback, useEffect } from 'react';
import { createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { setFallbackSession } from '../../../state/sessions';
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
import { getOidcCallbackUrl } from './oidcConfig';
import { parseOidcCallbackParams } from './oidcState';
/**
* Exchange the authorization code for a Matrix session and persist it. The SDK
* (oidc-client-ts) reads the transient PKCE/state it stored in sessionStorage
* before the redirect and validates `state` (CSRF). `redirectUri` is the
* deterministic callback URL, recomputed here.
*/
const completeOidcLogin = async (code: string, state: string): Promise<void> => {
const { tokenResponse, homeserverUrl, oidcClientSettings, idTokenClaims } =
await completeAuthorizationCodeGrant(code, state);
// Derive Matrix user_id + device_id from the freshly-issued access token (the
// device is owned by the authentication service under MSC3861).
const tmp = createClient({ baseUrl: homeserverUrl, accessToken: tokenResponse.access_token });
const { user_id: userId, device_id: deviceId } = await tmp.whoami();
if (!userId || !deviceId) {
throw new Error('Homeserver did not return a user/device for the OIDC session.');
}
setFallbackSession(tokenResponse.access_token, deviceId, userId, homeserverUrl, {
refreshToken: tokenResponse.refresh_token,
expiresInMs: tokenResponse.expires_in ? tokenResponse.expires_in * 1000 : undefined,
oidc: {
issuer: oidcClientSettings.issuer,
clientId: oidcClientSettings.clientId,
redirectUri: getOidcCallbackUrl(),
idTokenClaims: idTokenClaims as unknown as Record<string, unknown>,
},
});
};
function CallbackError({ message }: { message: string }) {
return (
<Box
direction="Column"
gap="400"
alignItems="Center"
justifyContent="Center"
style={{ height: '100dvh', padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" filled src={Icons.Warning} style={{ color: color.Critical.Main }} />
<Text size="H3">Sign-in failed</Text>
<Text size="T300" priority="300">
{message}
</Text>
{/* Rendered outside the router, so use a plain navigation to the app root. */}
<Button variant="Primary" onClick={() => window.location.assign(import.meta.env.BASE_URL)}>
<Text as="span" size="B400">
Back to login
</Text>
</Button>
</Box>
);
}
/**
* Standalone page mounted by App.tsx for the OIDC redirect path (before the
* router, since the redirect_uri must be a real non-hash path).
*/
export function OidcCallback() {
const params = parseOidcCallbackParams(window.location.search);
const [state, complete] = useAsyncCallback<void, unknown, [string, string]>(
useCallback(completeOidcLogin, []),
);
useEffect(() => {
if (params.kind === 'success') complete(params.code, params.state);
}, [params, complete]);
useEffect(() => {
// Session persisted — full-page reload at the app root so it boots the
// authenticated client (works for both hash and browser router configs).
if (state.status === AsyncStatus.Success) {
window.location.replace(import.meta.env.BASE_URL);
}
}, [state.status]);
if (params.kind === 'error') {
return <CallbackError message={params.errorDescription || params.error} />;
}
if (params.kind === 'invalid') {
return (
<CallbackError message="This sign-in link is invalid or has expired. Please start over." />
);
}
if (state.status === AsyncStatus.Error) {
return <CallbackError message="Failed to complete sign-in. Please try again." />;
}
return (
<Box
direction="Column"
gap="400"
alignItems="Center"
justifyContent="Center"
style={{ height: '100dvh' }}
>
<Spinner size="600" variant="Secondary" />
<Text size="T300" priority="300">
Signing you in
</Text>
</Box>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import LotusLogo from '../../../../../public/res/Lotus.png';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
/**
* Absolute URL the OIDC provider redirects back to after authorization.
*
* It MUST be a real (non-hash) path on our origin: OAuth redirect_uris cannot
* contain a fragment, and with hashRouter the app's routes live after `#`. We
* therefore always build it against the plain origin base — `getOriginBaseUrl()`
* with NO hashRouter arg returns `${origin}${BASE_URL}` (no `#`) — and App.tsx
* short-circuits this path before the router mounts.
*/
export const getOidcCallbackUrl = (): string =>
withOriginBaseUrl(getOriginBaseUrl(), OIDC_CALLBACK_PATH);
/**
* Client metadata sent during MSC2966 dynamic client registration.
*
* `registerOidcClient` drops any URI that doesn't share `clientUri` as a common
* base, so every URI here lives under our origin base.
*/
export const getOidcClientMetadata = (): OidcRegistrationClientMetadata => {
// `${origin}${BASE_URL}` (with trailing slash) — the common base for all URIs.
const clientUri = getOriginBaseUrl();
return {
clientName: 'Lotus Chat',
clientUri,
logoUri: new URL(LotusLogo, window.location.origin).href,
applicationType: 'web',
contacts: ['support@lotusguild.org'],
tosUri: clientUri,
policyUri: clientUri,
redirectUris: [getOidcCallbackUrl()],
};
};
+49
View File
@@ -0,0 +1,49 @@
import {
completeAuthorizationCodeGrant,
discoverAndValidateOIDCIssuerWellKnown,
generateOidcAuthorizationUrl,
OidcClientConfig,
registerOidcClient,
} from 'matrix-js-sdk';
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
import { cacheClientId, getCachedClientId, invalidateCachedClient } from './oidcState';
export { completeAuthorizationCodeGrant };
export const getOrRegisterClientId = async (
config: OidcClientConfig,
issuer: string,
redirectUri: string,
): Promise<string> => {
const cached = getCachedClientId(issuer, redirectUri);
if (cached) return cached;
const clientId = await registerOidcClient(config, getOidcClientMetadata());
cacheClientId(issuer, clientId, redirectUri);
return clientId;
};
/**
* Begin the OIDC authorization-code flow: discover + validate the issuer,
* (re)register a client, build the PKCE authorization URL (oidc-client-ts
* persists the transient code_verifier/state/nonce/homeserverUrl into
* sessionStorage for the callback), then redirect.
*/
export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string): Promise<void> => {
const redirectUri = getOidcCallbackUrl();
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(issuer);
const clientId = await getOrRegisterClientId(config, issuer, redirectUri);
const url = await generateOidcAuthorizationUrl({
metadata: config,
redirectUri,
clientId,
homeserverUrl: homeserverBaseUrl,
nonce: crypto.randomUUID(),
});
window.location.assign(url);
} catch (e) {
// Drop a possibly-stale cached client so the next attempt re-registers.
invalidateCachedClient(issuer);
throw e;
}
};
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getCachedClientId,
cacheClientId,
invalidateCachedClient,
parseOidcCallbackParams,
} from './oidcState';
const installStorage = (): Map<string, string> => {
const store = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => {
store.set(k, String(v));
},
removeItem: (k: string) => {
store.delete(k);
},
};
return store;
};
test('registration cache: get / put / invalidate, scoped by issuer + redirectUri', () => {
installStorage();
assert.equal(getCachedClientId('iss', 'rd'), undefined);
cacheClientId('iss', 'client-1', 'rd');
assert.equal(getCachedClientId('iss', 'rd'), 'client-1');
assert.equal(getCachedClientId('iss', 'other-redirect'), undefined); // redirect mismatch = miss
assert.equal(getCachedClientId('iss2', 'rd'), undefined); // different issuer = miss
invalidateCachedClient('iss');
assert.equal(getCachedClientId('iss', 'rd'), undefined);
invalidateCachedClient('absent'); // no-op, no throw
});
test('registration cache tolerates corrupt storage', () => {
const store = installStorage();
store.set('cinny_oidc_dynamic_clients', '{ not json');
assert.equal(getCachedClientId('iss', 'rd'), undefined);
cacheClientId('iss', 'c', 'rd');
assert.equal(getCachedClientId('iss', 'rd'), 'c');
});
test('parseOidcCallbackParams classifies success / error / invalid', () => {
assert.deepEqual(parseOidcCallbackParams('?code=abc&state=xyz'), {
kind: 'success',
code: 'abc',
state: 'xyz',
});
assert.deepEqual(parseOidcCallbackParams('?error=access_denied&error_description=nope'), {
kind: 'error',
error: 'access_denied',
errorDescription: 'nope',
});
assert.deepEqual(parseOidcCallbackParams('?error=bad'), {
kind: 'error',
error: 'bad',
errorDescription: undefined,
});
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
});
+60
View File
@@ -0,0 +1,60 @@
// Pure OIDC-login state helpers (no imports) so they're unit-testable under the
// tsx + node:test harness. The SDK-driven flow lives in oidcLoginUtil.ts.
/**
* Dynamic-registration cache. MAS issues a `client_id` per dynamic registration;
* caching by issuer (scoped to the current redirectUri) avoids re-registering on
* every login. Invalidated when the redirectUri changes or the provider later
* rejects the cached id (`invalid_client`).
*/
const REG_CACHE_KEY = 'cinny_oidc_dynamic_clients';
type RegEntry = { clientId: string; redirectUri: string };
type RegCache = Record<string, RegEntry>;
const readRegCache = (): RegCache => {
try {
const raw = localStorage.getItem(REG_CACHE_KEY);
return raw ? (JSON.parse(raw) as RegCache) : {};
} catch {
return {};
}
};
const writeRegCache = (cache: RegCache): void => {
localStorage.setItem(REG_CACHE_KEY, JSON.stringify(cache));
};
export const getCachedClientId = (issuer: string, redirectUri: string): string | undefined => {
const entry = readRegCache()[issuer];
return entry && entry.redirectUri === redirectUri ? entry.clientId : undefined;
};
export const cacheClientId = (issuer: string, clientId: string, redirectUri: string): void => {
const cache = readRegCache();
cache[issuer] = { clientId, redirectUri };
writeRegCache(cache);
};
export const invalidateCachedClient = (issuer: string): void => {
const cache = readRegCache();
if (cache[issuer]) {
delete cache[issuer];
writeRegCache(cache);
}
};
/** Parsed shape of the provider's redirect back to our callback URL. */
export type OidcCallbackParams =
| { kind: 'success'; code: string; state: string }
| { kind: 'error'; error: string; errorDescription?: string }
| { kind: 'invalid' };
/** Pure: classify the callback query string into success / error / invalid. */
export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
const params = new URLSearchParams(search);
const error = params.get('error');
if (error) {
return { kind: 'error', error, errorDescription: params.get('error_description') ?? undefined };
}
const code = params.get('code');
const state = params.get('state');
if (code && state) return { kind: 'success', code, state };
return { kind: 'invalid' };
};
+6
View File
@@ -19,6 +19,12 @@ export type ResetPasswordPathSearchParams = {
}; };
export const RESET_PASSWORD_PATH = '/reset-password/:server?/'; export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
// OIDC/next-gen-auth (MSC3861) authorization-code callback. This is a REAL
// (non-hash) path: OAuth redirect_uris cannot contain a fragment, so it must not
// live under the hash router. App.tsx short-circuits this path before the router
// mounts. The provider returns `?code&state` (or `?error`) on the query string.
export const OIDC_CALLBACK_PATH = '/auth/oidc/callback';
export const _CREATE_PATH = 'create/'; export const _CREATE_PATH = 'create/';
export const _JOIN_PATH = 'join/'; export const _JOIN_PATH = 'join/';
export const _LOBBY_PATH = 'lobby/'; export const _LOBBY_PATH = 'lobby/';
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { testBadWords, BAD_WORDS_REGEX } from './bad-words';
test('testBadWords returns false for clean text', () => {
assert.equal(testBadWords('hello world'), false);
assert.equal(testBadWords('this is clean text'), false);
assert.equal(testBadWords(''), false);
});
test('testBadWords matches custom additions', () => {
// 'torture' and 't0rture' are appended in additionalBadWords
assert.equal(testBadWords('torture'), true);
assert.equal(testBadWords('t0rture'), true);
});
test('testBadWords is case-insensitive', () => {
assert.equal(testBadWords('Torture'), true);
assert.equal(testBadWords('TORTURE'), true);
assert.equal(testBadWords('DamN'), true);
});
test('testBadWords matches words from the base list', () => {
assert.equal(testBadWords('damn'), true);
assert.equal(testBadWords('hell'), true);
assert.equal(testBadWords('crap'), true);
});
test('testBadWords respects word boundaries (no match inside a larger word)', () => {
// alphanumeric extension on either side prevents a match
assert.equal(testBadWords('tortured'), false);
assert.equal(testBadWords('tortures'), false);
assert.equal(testBadWords('damning'), false);
assert.equal(testBadWords('hello'), false);
assert.equal(testBadWords('shell'), false);
assert.equal(testBadWords('crappy'), false);
});
test('testBadWords does not match a bad word as a substring of an unrelated word', () => {
assert.equal(testBadWords('class'), false);
assert.equal(testBadWords('pass'), false);
assert.equal(testBadWords('grass'), false);
});
test('underscore acts as a word boundary', () => {
// surrounding underscores still allow a match, since `_` is a boundary char
assert.equal(testBadWords('word_torture_word'), true);
// but an alphanumeric char glued directly to the word still blocks it
assert.equal(testBadWords('tor_ture'), false);
});
test('testBadWords matches a bad word embedded in a sentence with spaces', () => {
assert.equal(testBadWords('what the hell is this'), true);
assert.equal(testBadWords('I think this is fine'), false);
});
test('BAD_WORDS_REGEX is a global regex; reuse it via String.match', () => {
// testBadWords lowercases the input before matching
assert.ok('torture'.toLowerCase().match(BAD_WORDS_REGEX));
assert.equal('innocent'.toLowerCase().match(BAD_WORDS_REGEX), null);
});
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getEmoticonSearchStr } from './utils';
import { PackImageReader } from './custom-emoji';
import { IEmoji } from './emoji';
test('getEmoticonSearchStr for a PackImageReader with a body returns shortcode + body', () => {
const reader = new PackImageReader('cat', 'mxc://server/cat', { body: 'kitten' });
assert.deepEqual(getEmoticonSearchStr(reader), [':cat:', 'kitten']);
});
test('getEmoticonSearchStr for a PackImageReader without a body returns just the shortcode string', () => {
const reader = new PackImageReader('cat', 'mxc://server/cat', {});
assert.equal(getEmoticonSearchStr(reader), ':cat:');
});
test('getEmoticonSearchStr ignores a non-string body on a PackImageReader', () => {
const reader = new PackImageReader('cat', 'mxc://server/cat', {
body: 123 as unknown as string,
});
assert.equal(getEmoticonSearchStr(reader), ':cat:');
});
test('getEmoticonSearchStr for an IEmoji concats shortcode, label and shortcodes', () => {
const emoji = {
shortcode: 'smile',
label: 'Smiling Face',
shortcodes: ['smile', 'happy'],
} as unknown as IEmoji;
assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face', 'smile', 'happy']);
});
test('getEmoticonSearchStr for an IEmoji without a shortcodes array returns shortcode + label', () => {
const emoji = {
shortcode: 'smile',
label: 'Smiling Face',
} as unknown as IEmoji;
assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face']);
});
+137
View File
@@ -0,0 +1,137 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Room } from 'matrix-js-sdk';
import { getViaServers } from './via-servers';
type StubEvent =
| {
content: Record<string, unknown>;
sender?: string;
}
| undefined;
type RoomStub = {
members: string[];
create?: StubEvent;
power?: StubEvent;
};
const makeRoom = (opts: RoomStub): Room => {
const stateEvents: Record<string, StubEvent> = {};
if (opts.create) stateEvents['m.room.create'] = opts.create;
if (opts.power) stateEvents['m.room.power_levels'] = opts.power;
const mkEvent = (e: StubEvent) =>
e
? {
getContent: () => e.content,
getSender: () => e.sender,
}
: undefined;
return {
getMembers: () => opts.members.map((userId) => ({ userId })),
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: (type: string) => mkEvent(stateEvents[type]),
}),
}),
} as unknown as Room;
};
test('with no power user it returns the top 3 most-populated servers, descending', () => {
const room = makeRoom({
// s3 x2, s1 x2, s2 x1, s4 x1 — order between equal counts follows insertion
members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s4.com'],
});
assert.deepEqual(getViaServers(room), ['s1.com', 's3.com', 's2.com']);
});
test('the highest-power user server is placed first, ahead of the populous servers', () => {
const room = makeRoom({
// s3 is most populous (3), but the power user is on s2
members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s3.com'],
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
});
// power-user server first, then the top populated servers, s2 deduped
assert.deepEqual(getViaServers(room), ['s2.com', 's3.com', 's1.com']);
});
test('a power-user server that is also the most populous is deduped, result capped at 3', () => {
const room = makeRoom({
members: [
'@a:s1.com',
'@b:s1.com',
'@c:s1.com',
'@d:s2.com',
'@e:s3.com',
'@f:s4.com',
'@g:s5.com',
],
power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } },
});
// s1 (power + most pop) first, then next 2 of the top-3 populated list
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com', 's3.com']);
});
test('picks the user with the strictly highest power level', () => {
const room = makeRoom({
members: ['@low:s1.com', '@high:s2.com', '@c:s3.com'],
power: {
content: {
users: { '@low:s1.com': 50, '@high:s2.com': 100 },
users_default: 0,
},
},
});
assert.equal(getViaServers(room)[0], 's2.com');
});
test('ignores users whose power is not above users_default', () => {
const room = makeRoom({
members: ['@a:s1.com', '@b:s2.com'],
// @a power equals users_default, so it is not treated as a power user
power: { content: { users: { '@a:s1.com': 50 }, users_default: 50 } },
});
// falls back to populated servers only
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']);
});
test('uses the create-event sender when the room version supports creators', () => {
const room = makeRoom({
members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'],
create: { content: { room_version: '12' }, sender: '@a:s1.com' },
// power levels would point elsewhere, but creatorsSupported short-circuits
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
});
assert.equal(getViaServers(room)[0], 's1.com');
});
test('falls back to power levels when the room version does not support creators', () => {
const room = makeRoom({
members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'],
create: { content: { room_version: '11' }, sender: '@a:s1.com' },
power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } },
});
assert.equal(getViaServers(room)[0], 's2.com');
});
test('ignores members with unparseable user ids when counting populations', () => {
const room = makeRoom({
members: ['@a:s1.com', 'broken-id', '@b:s1.com', '@c:s2.com'],
});
assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']);
});
test('returns an empty array for a room with no resolvable servers', () => {
const room = makeRoom({ members: ['broken-id', 'also-broken'] });
assert.deepEqual(getViaServers(room), []);
});
test('result never exceeds three servers', () => {
const room = makeRoom({
members: ['@a:s1.com', '@b:s2.com', '@c:s3.com', '@d:s4.com', '@e:s5.com'],
power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } },
});
assert.ok(getViaServers(room).length <= 3);
});
+42
View File
@@ -72,3 +72,45 @@ test('removeFallbackSession clears all keys', () => {
assert.equal(store.size, 0); assert.equal(store.size, 0);
assert.equal(getFallbackSession(), undefined); assert.equal(getFallbackSession(), undefined);
}); });
test('round-trips an OIDC session (refresh token, expiry, oidc metadata)', () => {
installStorage();
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://matrix-client.mozilla.org', {
refreshToken: 'refresh-xyz',
expiresInMs: 3_600_000,
oidc: {
issuer: 'https://chat.mozilla.org/',
clientId: 'client-123',
redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
},
});
const s = getFallbackSession();
assert.ok(s);
assert.equal(s.refreshToken, 'refresh-xyz');
// stored as absolute expiry, read back as remaining lifetime
assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
assert.deepEqual(s.oidc, {
issuer: 'https://chat.mozilla.org/',
clientId: 'client-123',
redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
});
});
test('a password session carries no OIDC fields, and re-saving clears stale OIDC keys', () => {
installStorage();
// first an OIDC session...
setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
refreshToken: 'r',
oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
});
assert.ok(getFallbackSession()?.oidc);
// ...overwritten by a plain password session must drop the OIDC state
setFallbackSession('tok2', 'DEV', '@alice:example.org', 'https://hs');
const s = getFallbackSession();
assert.ok(s);
assert.equal(s.oidc, undefined);
assert.equal(s.refreshToken, undefined);
});
+79
View File
@@ -5,6 +5,16 @@
// setLocalStorageItem, // setLocalStorageItem,
// } from './utils/atomWithLocalStorage'; // } from './utils/atomWithLocalStorage';
// OIDC/next-gen-auth (MSC3861) metadata needed to refresh + revoke tokens. Kept
// as a generic claims object so this core module stays decoupled from
// oidc-client-ts (the refresher casts idTokenClaims to IdTokenClaims).
export type OidcSessionMeta = {
issuer: string;
clientId: string;
redirectUri: string;
idTokenClaims?: Record<string, unknown>;
};
export type Session = { export type Session = {
baseUrl: string; baseUrl: string;
userId: string; userId: string;
@@ -13,6 +23,23 @@ export type Session = {
expiresInMs?: number; expiresInMs?: number;
refreshToken?: string; refreshToken?: string;
fallbackSdkStores?: boolean; fallbackSdkStores?: boolean;
oidc?: OidcSessionMeta;
};
// OIDC-only localStorage keys (absent for password/legacy-SSO sessions).
const OIDC_KEYS = {
refreshToken: 'cinny_refresh_token',
expiresAt: 'cinny_expires_at',
issuer: 'cinny_oidc_issuer',
clientId: 'cinny_oidc_client_id',
redirectUri: 'cinny_oidc_redirect_uri',
idTokenClaims: 'cinny_oidc_id_token_claims',
} as const;
export type FallbackSessionExtra = {
refreshToken?: string;
expiresInMs?: number;
oidc?: OidcSessionMeta;
}; };
export type Sessions = Session[]; export type Sessions = Session[];
@@ -34,17 +61,43 @@ export function setFallbackSession(
deviceId: string, deviceId: string,
userId: string, userId: string,
baseUrl: string, baseUrl: string,
extra?: FallbackSessionExtra,
) { ) {
localStorage.setItem('cinny_access_token', accessToken); localStorage.setItem('cinny_access_token', accessToken);
localStorage.setItem('cinny_device_id', deviceId); localStorage.setItem('cinny_device_id', deviceId);
localStorage.setItem('cinny_user_id', userId); localStorage.setItem('cinny_user_id', userId);
localStorage.setItem('cinny_hs_base_url', baseUrl); localStorage.setItem('cinny_hs_base_url', baseUrl);
// OIDC fields — written only when present; otherwise cleared so a password
// session never carries stale OIDC state.
if (extra?.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, extra.refreshToken);
else localStorage.removeItem(OIDC_KEYS.refreshToken);
if (typeof extra?.expiresInMs === 'number') {
// Store ABSOLUTE expiry to avoid drift across reloads.
localStorage.setItem(OIDC_KEYS.expiresAt, String(Date.now() + extra.expiresInMs));
} else localStorage.removeItem(OIDC_KEYS.expiresAt);
if (extra?.oidc) {
localStorage.setItem(OIDC_KEYS.issuer, extra.oidc.issuer);
localStorage.setItem(OIDC_KEYS.clientId, extra.oidc.clientId);
localStorage.setItem(OIDC_KEYS.redirectUri, extra.oidc.redirectUri);
if (extra.oidc.idTokenClaims) {
localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(extra.oidc.idTokenClaims));
} else localStorage.removeItem(OIDC_KEYS.idTokenClaims);
} else {
localStorage.removeItem(OIDC_KEYS.issuer);
localStorage.removeItem(OIDC_KEYS.clientId);
localStorage.removeItem(OIDC_KEYS.redirectUri);
localStorage.removeItem(OIDC_KEYS.idTokenClaims);
}
} }
export const removeFallbackSession = () => { export const removeFallbackSession = () => {
localStorage.removeItem('cinny_hs_base_url'); localStorage.removeItem('cinny_hs_base_url');
localStorage.removeItem('cinny_user_id'); localStorage.removeItem('cinny_user_id');
localStorage.removeItem('cinny_device_id'); localStorage.removeItem('cinny_device_id');
localStorage.removeItem('cinny_access_token'); localStorage.removeItem('cinny_access_token');
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
}; };
export const getFallbackSession = (): Session | undefined => { export const getFallbackSession = (): Session | undefined => {
const baseUrl = localStorage.getItem('cinny_hs_base_url'); const baseUrl = localStorage.getItem('cinny_hs_base_url');
@@ -61,6 +114,32 @@ export const getFallbackSession = (): Session | undefined => {
fallbackSdkStores: true, fallbackSdkStores: true,
}; };
const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken);
if (refreshToken) session.refreshToken = refreshToken;
const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt);
if (expiresAtRaw) {
const expiresAt = Number(expiresAtRaw);
// Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401.
if (Number.isFinite(expiresAt)) session.expiresInMs = Math.max(0, expiresAt - Date.now());
}
const issuer = localStorage.getItem(OIDC_KEYS.issuer);
const clientId = localStorage.getItem(OIDC_KEYS.clientId);
const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri);
if (issuer && clientId && redirectUri) {
let idTokenClaims: Record<string, unknown> | undefined;
const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims);
if (claimsRaw) {
try {
idTokenClaims = JSON.parse(claimsRaw);
} catch {
/* corrupt claims — ignore, the refresher will re-validate on use */
}
}
session.oidc = { issuer, clientId, redirectUri, idTokenClaims };
}
return session; return session;
} }
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatFileSize, isCompressible } from './imageCompression';
test('formatFileSize formats bytes below 1KB as B', () => {
assert.equal(formatFileSize(0), '0 B');
assert.equal(formatFileSize(1), '1 B');
assert.equal(formatFileSize(512), '512 B');
assert.equal(formatFileSize(1023), '1023 B');
});
test('formatFileSize formats values in the KB range', () => {
assert.equal(formatFileSize(1024), '1.0 KB');
assert.equal(formatFileSize(1536), '1.5 KB');
// 1024 * 1024 - 1 is still under the MB boundary
assert.equal(formatFileSize(1024 * 1024 - 1), '1024.0 KB');
});
test('formatFileSize formats values in the MB range', () => {
assert.equal(formatFileSize(1024 * 1024), '1.0 MB');
assert.equal(formatFileSize(1024 * 1024 * 1.5), '1.5 MB');
assert.equal(formatFileSize(1024 * 1024 * 10), '10.0 MB');
});
test('isCompressible accepts raster image types', () => {
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
});
test('isCompressible rejects svg, empty type, and non-images', () => {
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
assert.equal(isCompressible({ type: '' } as Blob), false);
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
});
+117
View File
@@ -0,0 +1,117 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { tokenize, tokenStyle, SyntaxToken } from './syntaxHighlight';
const find = (tokens: SyntaxToken[], text: string) => tokens.find((t) => t.text === text);
test('tokenize falls back to a single plain token for unsupported languages', () => {
assert.deepEqual(tokenize('plain text here', 'unknownlang'), [
{ text: 'plain text here', type: 'plain' },
]);
// empty lang is also unsupported
assert.deepEqual(tokenize('hello', ''), [{ text: 'hello', type: 'plain' }]);
});
test('tokenize returns an empty array for empty input', () => {
assert.deepEqual(tokenize('', 'js'), []);
});
test('tokenize strips a leading "language-" prefix', () => {
const tokens = tokenize('const x', 'language-javascript');
assert.equal(find(tokens, 'const')?.type, 'kw');
});
test('tokenize classifies keywords, numbers, plain words and punctuation', () => {
const tokens = tokenize('const x = 5;', 'js');
assert.deepEqual(tokens, [
{ text: 'const', type: 'kw' },
{ text: ' ', type: 'plain' },
{ text: 'x', type: 'plain' },
{ text: ' = ', type: 'plain' },
{ text: '5', type: 'num' },
{ text: ';', type: 'plain' },
]);
});
test('tokenize detects function-call identifiers by a following paren', () => {
const tokens = tokenize('foo(1)', 'js');
assert.equal(find(tokens, 'foo')?.type, 'fn');
assert.equal(find(tokens, '1')?.type, 'num');
});
test('tokenize handles line comments up to the newline', () => {
const tokens = tokenize('// hi\ncode', 'js');
assert.deepEqual(tokens[0], { text: '// hi', type: 'cmt' });
// the newline and the rest are plain
assert.equal(find(tokens, 'code')?.type, 'plain');
});
test('tokenize handles block comments including the closing delimiter', () => {
const tokens = tokenize('/* block */x', 'js');
assert.deepEqual(tokens[0], { text: '/* block */', type: 'cmt' });
assert.equal(find(tokens, 'x')?.type, 'plain');
});
test('tokenize captures string literals including escaped quotes', () => {
assert.deepEqual(tokenize('"str"', 'js'), [{ text: '"str"', type: 'str' }]);
assert.deepEqual(tokenize("'a\\'b'", 'js'), [{ text: "'a\\'b'", type: 'str' }]);
});
test('tokenize treats # as a comment only in python', () => {
// python: # after a space starts a comment
const py = tokenize('a # not comment', 'python');
assert.equal(
py.some((t) => t.type === 'cmt' && t.text === '# not comment'),
true,
);
// js: # is not a comment marker
const js = tokenize('a # b', 'js');
assert.equal(
js.some((t) => t.type === 'cmt'),
false,
);
});
test('tokenize uses the python keyword set for python', () => {
const tokens = tokenize('def foo():', 'python');
assert.equal(find(tokens, 'def')?.type, 'kw');
assert.equal(find(tokens, 'foo')?.type, 'fn');
});
test('tokenize uses the rust keyword set for rust', () => {
const tokens = tokenize('fn main() {}', 'rust');
assert.equal(find(tokens, 'fn')?.type, 'kw');
assert.equal(find(tokens, 'main')?.type, 'fn');
});
test('tokenize keeps a plain word that is a keyword in another language plain', () => {
// "def" is a python keyword but not a js keyword
const tokens = tokenize('def x', 'js');
assert.equal(find(tokens, 'def')?.type, 'plain');
});
test('tokenize re-concatenates to the original source', () => {
const samples: Array<[string, string]> = [
['const x = foo(42); // done', 'js'],
['def add(a, b):\n return a + b # sum', 'python'],
['let mut v = vec![1, 2, 3];', 'rust'],
];
for (const [code, lang] of samples) {
assert.equal(
tokenize(code, lang)
.map((t) => t.text)
.join(''),
code,
`roundtrip for ${lang}`,
);
}
});
test('tokenStyle returns distinct styles per token kind', () => {
assert.deepEqual(tokenStyle('kw'), { color: 'var(--prism-keyword)' });
assert.deepEqual(tokenStyle('str'), { color: 'var(--prism-selector)' });
assert.deepEqual(tokenStyle('num'), { color: 'var(--prism-boolean)' });
assert.deepEqual(tokenStyle('cmt'), { color: 'var(--prism-comment)', fontStyle: 'italic' });
assert.deepEqual(tokenStyle('fn'), { color: 'var(--prism-atrule)' });
assert.deepEqual(tokenStyle('plain'), {});
});
+67
View File
@@ -0,0 +1,67 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { isMacOS, mobileOrTablet } from './user-agent';
// `ua()` reads `window.navigator.userAgent` lazily on every call, so we can swap
// the global mock per test case and restore it afterwards.
const UA = {
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
windows:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
linux:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
iphone:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
ipad: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
android:
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
};
const g = globalThis as unknown as { window?: unknown };
const originalWindow = g.window;
const setUserAgent = (userAgent: string) => {
g.window = { navigator: { userAgent } };
};
afterEach(() => {
g.window = originalWindow;
});
test('isMacOS returns true for a Mac user agent (handles both macOS and legacy Mac OS)', () => {
setUserAgent(UA.mac);
assert.equal(isMacOS(), true);
});
test('isMacOS returns false for non-Mac user agents', () => {
setUserAgent(UA.windows);
assert.equal(isMacOS(), false);
setUserAgent(UA.linux);
assert.equal(isMacOS(), false);
setUserAgent(UA.android);
assert.equal(isMacOS(), false);
});
test('mobileOrTablet is true for an iPhone', () => {
setUserAgent(UA.iphone);
assert.equal(mobileOrTablet(), true);
});
test('mobileOrTablet is true for an iPad', () => {
setUserAgent(UA.ipad);
assert.equal(mobileOrTablet(), true);
});
test('mobileOrTablet is true for an Android device', () => {
setUserAgent(UA.android);
assert.equal(mobileOrTablet(), true);
});
test('mobileOrTablet is false for desktop user agents', () => {
setUserAgent(UA.mac);
assert.equal(mobileOrTablet(), false);
setUserAgent(UA.windows);
assert.equal(mobileOrTablet(), false);
setUserAgent(UA.linux);
assert.equal(mobileOrTablet(), false);
});
+6 -1
View File
@@ -2,7 +2,12 @@ import { UAParser } from 'ua-parser-js';
export const ua = () => UAParser(window.navigator.userAgent); export const ua = () => UAParser(window.navigator.userAgent);
export const isMacOS = () => ua().os.name === 'Mac OS'; // ua-parser-js reports macOS as 'macOS' (v2+); older versions used 'Mac OS'.
// Accept both so the ⌘-vs-Ctrl shortcut hints render correctly on real Macs.
export const isMacOS = () => {
const name = ua().os.name;
return name === 'macOS' || name === 'Mac OS';
};
export const mobileOrTablet = (): boolean => { export const mobileOrTablet = (): boolean => {
const userAgent = ua(); const userAgent = ua();
+19 -8
View File
@@ -2,16 +2,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys'; import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { removeFallbackSession } from '../app/state/sessions'; import { getFallbackSession, removeFallbackSession, Session } from '../app/state/sessions';
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout';
import { pushSessionToSW } from '../sw-session'; import { pushSessionToSW } from '../sw-session';
type Session = {
baseUrl: string;
accessToken: string;
userId: string;
deviceId: string;
};
// Thrown when the local IndexedDB has a higher schema version than this SDK expects. // Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT'; export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
@@ -25,9 +20,17 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store'); const legacyCryptoStore = new IndexedDBCryptoStore(globalThis.indexedDB, 'crypto-store');
// OIDC/next-gen-auth sessions carry a refresh token; wire automatic refresh
// (the client calls this reactively on a 401) and persist rotated tokens.
const oidcRefresher =
session.refreshToken && session.oidc
? new LotusOidcTokenRefresher(session.oidc, session.deviceId, session.userId, session.baseUrl)
: undefined;
const mx = createClient({ const mx = createClient({
baseUrl: session.baseUrl, baseUrl: session.baseUrl,
accessToken: session.accessToken, accessToken: session.accessToken,
refreshToken: session.refreshToken,
userId: session.userId, userId: session.userId,
store: indexedDBStore, store: indexedDBStore,
cryptoStore: legacyCryptoStore, cryptoStore: legacyCryptoStore,
@@ -35,6 +38,9 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
timelineSupport: true, timelineSupport: true,
cryptoCallbacks: cryptoCallbacks as any, cryptoCallbacks: cryptoCallbacks as any,
verificationMethods: ['m.sas.v1'], verificationMethods: ['m.sas.v1'],
tokenRefreshFunction: oidcRefresher
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
: undefined,
}); });
try { try {
@@ -70,6 +76,11 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
export const logoutClient = async (mx: MatrixClient) => { export const logoutClient = async (mx: MatrixClient) => {
pushSessionToSW(); pushSessionToSW();
mx.stopClient(); mx.stopClient();
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
const session = getFallbackSession();
if (session?.oidc) {
await revokeOidcTokens(session);
}
try { try {
await mx.logout(); await mx.logout();
} catch { } catch {
+29
View File
@@ -0,0 +1,29 @@
import { discoverAndValidateOIDCIssuerWellKnown } from 'matrix-js-sdk';
import { Session } from '../app/state/sessions';
/**
* Best-effort revoke the OIDC access + refresh tokens at the issuer's revocation
* endpoint during logout. Tolerant of any failure logout proceeds regardless
* (the local session is cleared by the caller either way).
*/
export const revokeOidcTokens = async (session: Session): Promise<void> => {
if (!session.oidc) return;
try {
const config = await discoverAndValidateOIDCIssuerWellKnown(session.oidc.issuer);
const endpoint = config.revocation_endpoint;
if (!endpoint) return;
const { clientId } = session.oidc;
const revoke = (token: string, hint: string): Promise<Response> =>
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token, token_type_hint: hint, client_id: clientId }),
});
const requests: Promise<Response>[] = [];
if (session.refreshToken) requests.push(revoke(session.refreshToken, 'refresh_token'));
if (session.accessToken) requests.push(revoke(session.accessToken, 'access_token'));
await Promise.allSettled(requests);
} catch {
/* issuer unreachable / no revocation endpoint — ignore */
}
};
+42
View File
@@ -0,0 +1,42 @@
import { OidcTokenRefresher } from 'matrix-js-sdk';
import type { IdTokenClaims } from 'oidc-client-ts';
import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
/**
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
* so a page reload keeps the freshest access/refresh token. The matrix client
* calls this automatically (reactively on a 401) when a refresh token is set.
*/
export class LotusOidcTokenRefresher extends OidcTokenRefresher {
private readonly deviceIdRef: string;
private readonly userIdRef: string;
private readonly baseUrlRef: string;
private readonly oidcRef: OidcSessionMeta;
constructor(oidc: OidcSessionMeta, deviceId: string, userId: string, baseUrl: string) {
super(
oidc.issuer,
oidc.clientId,
oidc.redirectUri,
deviceId,
(oidc.idTokenClaims ?? {}) as unknown as IdTokenClaims,
);
this.deviceIdRef = deviceId;
this.userIdRef = userId;
this.baseUrlRef = baseUrl;
this.oidcRef = oidc;
}
protected async persistTokens(tokens: {
accessToken: string;
refreshToken?: string;
}): Promise<void> {
setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, {
refreshToken: tokens.refreshToken,
oidc: this.oidcRef,
});
}
}