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
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
162 lines
6.0 KiB
TypeScript
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);
|
|
});
|