Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efcee88f05 | |||
| 0b307037e0 | |||
| 67bd05fc96 | |||
| dd6b0bccb3 | |||
| a50d3e7ca7 | |||
| d3d2f9a448 | |||
| 98ad5674a8 | |||
| 30d0331174 |
@@ -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
|
||||
|
||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||
|
||||
+13
-7
@@ -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).
|
||||
**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.
|
||||
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity.
|
||||
**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.
|
||||
**Complexity:** Extreme. Multi-sprint project. Plan separately.
|
||||
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
|
||||
**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).
|
||||
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
|
||||
|
||||
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": ["localhost:8008"],
|
||||
"allowCustomHomeservers": true,
|
||||
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
|
||||
"hashRouter": { "enabled": false, "basename": "/" }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
});
|
||||
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||
'm.identity_server'?: {
|
||||
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'?: {
|
||||
account?: 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 (
|
||||
request: typeof fetch,
|
||||
server: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
|
||||
import { Profile } from './Profile';
|
||||
import { ContactInformation } from './ContactInfo';
|
||||
import { IgnoredUserList } from './IgnoredUserList';
|
||||
import { OidcManageAccount } from './OidcManageAccount';
|
||||
|
||||
type AccountProps = {
|
||||
requestClose: () => void;
|
||||
@@ -32,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
|
||||
<Box direction="Column" gap="700">
|
||||
<Profile />
|
||||
<MatrixId />
|
||||
<OidcManageAccount />
|
||||
<ContactInformation />
|
||||
<IgnoredUserList />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
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 =>
|
||||
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';
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
password?: LoginFlow;
|
||||
token?: LoginFlow;
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { OIDC_CALLBACK_PATH } from './paths';
|
||||
import { OidcCallback } from './auth/oidc/OidcCallback';
|
||||
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
@@ -111,6 +113,14 @@ function App() {
|
||||
|
||||
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 (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||
import { PasswordLoginForm } from './PasswordLoginForm';
|
||||
import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OidcLogin } from './OidcLogin';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { getOidcIssuer } from '../../../cs-api';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
@@ -36,6 +39,7 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
|
||||
|
||||
export function Login() {
|
||||
const server = useAuthServer();
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -54,6 +58,29 @@ export function Login() {
|
||||
|
||||
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 (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text as="h1" size="H2" priority="400">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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' };
|
||||
};
|
||||
@@ -19,6 +19,12 @@ export type ResetPasswordPathSearchParams = {
|
||||
};
|
||||
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 _JOIN_PATH = 'join/';
|
||||
export const _LOBBY_PATH = 'lobby/';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -72,3 +72,45 @@ test('removeFallbackSession clears all keys', () => {
|
||||
assert.equal(store.size, 0);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
// setLocalStorageItem,
|
||||
// } 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 = {
|
||||
baseUrl: string;
|
||||
userId: string;
|
||||
@@ -13,6 +23,23 @@ export type Session = {
|
||||
expiresInMs?: number;
|
||||
refreshToken?: string;
|
||||
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[];
|
||||
@@ -34,17 +61,43 @@ export function setFallbackSession(
|
||||
deviceId: string,
|
||||
userId: string,
|
||||
baseUrl: string,
|
||||
extra?: FallbackSessionExtra,
|
||||
) {
|
||||
localStorage.setItem('cinny_access_token', accessToken);
|
||||
localStorage.setItem('cinny_device_id', deviceId);
|
||||
localStorage.setItem('cinny_user_id', userId);
|
||||
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 = () => {
|
||||
localStorage.removeItem('cinny_hs_base_url');
|
||||
localStorage.removeItem('cinny_user_id');
|
||||
localStorage.removeItem('cinny_device_id');
|
||||
localStorage.removeItem('cinny_access_token');
|
||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
export const getFallbackSession = (): Session | undefined => {
|
||||
const baseUrl = localStorage.getItem('cinny_hs_base_url');
|
||||
@@ -61,6 +114,32 @@ export const getFallbackSession = (): Session | undefined => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'), {});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import { UAParser } from 'ua-parser-js';
|
||||
|
||||
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 => {
|
||||
const userAgent = ua();
|
||||
|
||||
@@ -2,16 +2,11 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
|
||||
|
||||
import { cryptoCallbacks } from './secretStorageKeys';
|
||||
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';
|
||||
|
||||
type Session = {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
// 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).
|
||||
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');
|
||||
|
||||
// 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({
|
||||
baseUrl: session.baseUrl,
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
userId: session.userId,
|
||||
store: indexedDBStore,
|
||||
cryptoStore: legacyCryptoStore,
|
||||
@@ -35,6 +38,9 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
verificationMethods: ['m.sas.v1'],
|
||||
tokenRefreshFunction: oidcRefresher
|
||||
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -70,6 +76,11 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||
export const logoutClient = async (mx: MatrixClient) => {
|
||||
pushSessionToSW();
|
||||
mx.stopClient();
|
||||
// For OIDC sessions, revoke the tokens at the issuer too (best-effort).
|
||||
const session = getFallbackSession();
|
||||
if (session?.oidc) {
|
||||
await revokeOidcTokens(session);
|
||||
}
|
||||
try {
|
||||
await mx.logout();
|
||||
} catch {
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user