Files
cinny/src/app/pages/auth/oidc/oidcState.test.ts
T
Lotus CIandClaude Opus 5.5 70206d6033
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m50s
chore(deps): matrix-js-sdk 41.7 -> 44.0 (#201)
Picks up three releases of MatrixRTC / delayed-event fixes and
matrix-sdk-crypto-wasm 18.3.1 -> 18.9.0.

SDK 43 moved OAuth (MSC3861) into the SDK and dropped oidc-client-ts, so
the native-OAuth sign-in is ported:
- login discovers the auth server via /auth_metadata, registers with
  OAuth2.registerClient, and keeps the PKCE verifier + device in its own
  sessionStorage entry keyed by `state` (single use; CSRF check);
- refresh is done by the SDK (oauthClientId + onTokenRefresh); the #16
  cross-tab protection is kept by a narrow guard on refresh-token grants
  (the SDK calls the global fetch for OAuth, not fetchFn), which
  serialises them with a Web Lock and adopts a sibling tab's tokens;
- logout and forced logout revoke via mx.logout() (the SDK revokes at
  the auth server for OAuth sessions);
- the callback page parsed its params on every render, re-running the
  single-use code exchange in a loop; parsed once now.

Also: notification_type is deprecated in MSC4075 (ring in DMs, notify
elsewhere when omitted), _onRTCSessionMemberUpdate ->
ensureRecalculateSessionMembers, precache limit 6 -> 10 MB for the
7.8 MB crypto wasm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-24 00:32:13 -04:00

162 lines
6.0 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getCachedClientId,
cacheClientId,
invalidateCachedClient,
parseOidcCallbackParams,
isStaleClientError,
readStoredOidcIssuer,
savePendingOidcLogin,
takePendingOidcLogin,
} 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;
};
const installSessionStorage = (): Map<string, string> => {
const store = new Map<string, string>();
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
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',
state: undefined,
});
assert.deepEqual(parseOidcCallbackParams('?error=bad'), {
kind: 'error',
error: 'bad',
errorDescription: undefined,
state: undefined,
});
// #102 — the provider echoes back `state` on an error redirect too; the
// callback needs it to look up the pending sign-in's issuer.
assert.deepEqual(parseOidcCallbackParams('?error=invalid_client&state=xyz'), {
kind: 'error',
error: 'invalid_client',
errorDescription: undefined,
state: 'xyz',
});
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
});
test('isStaleClientError: only client-rejection shapes invalidate the cache', () => {
// OAuth error responses naming the client (oidc-client-ts ErrorResponse shape).
assert.equal(isStaleClientError({ error: 'invalid_client' }), true);
assert.equal(isStaleClientError({ error: 'unauthorized_client' }), true);
// HTTP 400/401 from the registration/authorize step (MatrixError-style).
assert.equal(isStaleClientError({ httpStatus: 400 }), true);
assert.equal(isStaleClientError({ httpStatus: 401 }), true);
assert.equal(isStaleClientError({ status: 401 }), true);
assert.equal(isStaleClientError({ httpStatus: 500 }), false);
assert.equal(isStaleClientError({ httpStatus: 400, status: 200 }), true); // httpStatus wins
});
test('isStaleClientError: transient / discovery / local failures keep the cache', () => {
assert.equal(isStaleClientError(new TypeError('Failed to fetch')), false);
assert.equal(isStaleClientError(new Error('Something went wrong with OIDC discovery')), false);
assert.equal(isStaleClientError({ error: 'server_error' }), false);
assert.equal(isStaleClientError(new Error('crypto.randomUUID is not a function')), false);
assert.equal(isStaleClientError(undefined), false);
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
});
test('isStaleClientError: also recognises the OidcCallback error-redirect shape (#102)', () => {
// Same field name (`error`) as the ErrorResponse shape, so no special-casing
// is needed — but pin it down since the callback now depends on this.
assert.equal(
isStaleClientError({
kind: 'error',
error: 'invalid_client',
errorDescription: undefined,
state: 'xyz',
}),
true,
);
assert.equal(
isStaleClientError({
kind: 'error',
error: 'access_denied',
errorDescription: undefined,
state: 'xyz',
}),
false,
);
});
const pending = {
issuer: 'https://issuer.example/',
clientId: 'client1',
deviceId: 'DEVICEABCD',
codeVerifier: 'v'.repeat(96),
homeserverUrl: 'https://hs.example',
redirectUri: 'https://app.example/oidc/callback',
};
test('pending sign-in: saved by state, read for the issuer, consumed once', () => {
installSessionStorage();
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
savePendingOidcLogin('xyz', pending);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/');
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
assert.deepEqual(takePendingOidcLogin('xyz'), pending);
assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use');
});
test('pending sign-in tolerates corrupt or incomplete storage', () => {
const store = installSessionStorage();
store.set('lotus_oidc_pending_bad', '{ not json');
assert.equal(readStoredOidcIssuer('bad'), undefined);
store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' }));
assert.equal(takePendingOidcLogin('partial'), undefined);
});