Repository navigation
Develop #288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Develop #288
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| // Jest stand-in for expo-application, whose native module cannot load under Jest. The values are what the app header | ||
| // code reads (client-app.ts); tests that need others mock expo-application themselves, which takes precedence. | ||
| export const applicationName = 'Resgrid Unit'; | ||
| export const applicationId = 'com.resgrid.unit'; | ||
| export const nativeApplicationVersion = '1.0.0'; | ||
| export const nativeBuildVersion = '1'; | ||
| export const getInstallationTimeAsync = jest.fn(async () => new Date(0)); | ||
| export const getAndroidId = jest.fn(() => 'test-android-id'); | ||
| export const getIosIdForVendorAsync = jest.fn(async () => 'test-idfv'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| // Jest stand-in for expo-crypto, whose native AES classes cannot load under Jest. Randomness and digests use Node's | ||
| // crypto so PKCE values are real; tests that need fixed values still mock expo-crypto themselves, which takes precedence. | ||
| import { createHash, randomBytes, randomUUID as nodeRandomUUID } from 'crypto'; | ||
|
|
||
| export const CryptoDigestAlgorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA384: 'SHA-384', SHA512: 'SHA-512', MD5: 'MD5' } as const; | ||
| export const CryptoEncoding = { HEX: 'hex', BASE64: 'base64' } as const; | ||
|
|
||
| const nodeAlgorithm = (algorithm: string) => algorithm.replace('-', '').toLowerCase(); | ||
|
|
||
| export const getRandomBytes = (byteCount: number): Uint8Array => new Uint8Array(randomBytes(byteCount)); | ||
| export const getRandomBytesAsync = async (byteCount: number): Promise<Uint8Array> => getRandomBytes(byteCount); | ||
| export const randomUUID = (): string => nodeRandomUUID(); | ||
| export const digestStringAsync = async (algorithm: string, data: string, options?: { encoding?: string }): Promise<string> => | ||
| createHash(nodeAlgorithm(algorithm)) | ||
| .update(data) | ||
| .digest(options?.encoding === 'base64' ? 'base64' : 'hex'); | ||
| export const digest = async (algorithm: string, data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer> => { | ||
| // A copy into a fresh ArrayBuffer: Node's pooled Buffer may sit on a shared backing store. | ||
| return new Uint8Array(createHash(nodeAlgorithm(algorithm)).update(Buffer.from(data as ArrayBuffer)).digest()).buffer; | ||
| }; | ||
| export const aesEncryptAsync = jest.fn(); | ||
| export const aesDecryptAsync = jest.fn(); | ||
| export class AESEncryptionKey {} | ||
| export class AESSealedData {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| // Jest stand-in for react-native-passkey (a native module). Tests set the results they need on these mocks. | ||
| export const Passkey = { | ||
| isSupported: jest.fn(() => true), | ||
| get: jest.fn(), | ||
| create: jest.fn(), | ||
| getPlatformKey: jest.fn(), | ||
| createPlatformKey: jest.fn(), | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,310 @@ | ||
| /** | ||
| * Legacy SSO in the desktop app (electron/legacy-sso.js): the OIDC code flow run as a native client (PKCE and state, the | ||
| * provider in the member's browser, the code redeemed in the main process), the SAML start page, and the return on this | ||
| * app's own scheme, which only a waiting sign-in with the same state or RelayState takes. | ||
| * | ||
| * @jest-environment node | ||
| */ | ||
| import crypto from 'crypto'; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| const { createLegacySso, registerLegacySso } = require('../legacy-sso'); | ||
|
|
||
| const AUTHORITY = 'https://login.example-idp.com/tenant'; | ||
| const DISCOVERY = `${AUTHORITY}/.well-known/openid-configuration`; | ||
| const AUTHORIZE = 'https://login.example-idp.com/tenant/oauth2/authorize'; | ||
| const TOKEN = 'https://login.example-idp.com/tenant/oauth2/token'; | ||
| const START = 'https://api.resgrid.com/api/v4/connect/saml-mobile-login?departmentToken=enc&RelayState=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; | ||
|
|
||
| const json = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); | ||
| const base64Url = (bytes: Buffer) => bytes.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); | ||
| const tick = () => new Promise((resolve) => setImmediate(resolve)); | ||
|
|
||
| interface Fixture { | ||
| sso: ReturnType<typeof createLegacySso>; | ||
| opened: string[]; | ||
| requests: { url: string; init?: { method?: string; headers?: Record<string, string>; body?: string } }[]; | ||
| focus: jest.Mock; | ||
| } | ||
|
|
||
| const fixture = (overrides: { tokenAnswer?: unknown; discovery?: unknown; waitMs?: number; openExternal?: (url: string) => Promise<void> } = {}): Fixture => { | ||
| const opened: string[] = []; | ||
| const requests: Fixture['requests'] = []; | ||
| const focus = jest.fn(); | ||
| const fetch = jest.fn(async (url: string, init?: Fixture['requests'][number]['init']) => { | ||
| requests.push({ url, init }); | ||
| if (url === DISCOVERY) { | ||
| return 'discovery' in overrides ? overrides.discovery : json({ issuer: AUTHORITY, authorization_endpoint: AUTHORIZE, token_endpoint: TOKEN }); | ||
| } | ||
| if (url === TOKEN) { | ||
| return 'tokenAnswer' in overrides ? overrides.tokenAnswer : json({ id_token: 'idp.id.token', access_token: 'at', token_type: 'Bearer' }); | ||
| } | ||
| return json({}, 404); | ||
| }); | ||
| const openExternal = | ||
| overrides.openExternal ?? | ||
| (async (url: string) => { | ||
| opened.push(url); | ||
| }); | ||
| const sso = createLegacySso({ scheme: 'resgridunit', openExternal, fetch, focus, ...(overrides.waitMs ? { waitMs: overrides.waitMs } : {}) }); | ||
| return { sso, opened, requests, focus }; | ||
| }; | ||
|
|
||
| /** Starts an OIDC sign-in and waits until the provider page is open; returns the pending answer and the page. */ | ||
| const startOidc = async (f: Fixture) => { | ||
| const answer = f.sso.oidc(AUTHORITY, 'resgrid-desktop'); | ||
| for (let i = 0; i < 10 && f.opened.length === 0; i++) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Equality-based termination with Kody rule violation: Avoid equality operators in loop termination conditions for (let i = 0; i < 10 && f.opened.length < 1; i++) {Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| await tick(); | ||
| } | ||
| return { answer, page: new URL(f.opened[f.opened.length - 1]) }; | ||
| }; | ||
|
|
||
| describe('createLegacySso: OIDC', () => { | ||
| it("runs the code flow with PKCE in the member's browser and redeems the code here, on the app's registered redirect URI", async () => { | ||
| const f = fixture(); | ||
| const { answer, page } = await startOidc(f); | ||
|
|
||
| expect(`${page.origin}${page.pathname}`).toBe(AUTHORIZE); | ||
| expect(Object.fromEntries(page.searchParams)).toEqual({ | ||
| response_type: 'code', | ||
| client_id: 'resgrid-desktop', | ||
| redirect_uri: 'resgridunit://auth/callback', | ||
| scope: 'openid email profile offline_access', | ||
| state: expect.stringMatching(/^[A-Za-z0-9_-]{32}$/), | ||
| code_challenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), | ||
| code_challenge_method: 'S256', | ||
| }); | ||
| expect(f.sso.redirectUri).toBe('resgridunit://auth/callback'); | ||
|
|
||
| expect(f.sso.handleLink(`resgridunit://auth/callback?code=the-code&state=${page.searchParams.get('state')}`)).toBe(true); | ||
| await expect(answer).resolves.toEqual({ ok: true, idToken: 'idp.id.token' }); | ||
| expect(f.focus).toHaveBeenCalledTimes(1); | ||
|
|
||
| const redemption = f.requests.find((r) => r.url === TOKEN)!; | ||
| expect(redemption.init?.method).toBe('POST'); | ||
| expect(redemption.init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }); | ||
| const form = Object.fromEntries(new URLSearchParams(redemption.init?.body)); | ||
| expect(form).toEqual({ grant_type: 'authorization_code', code: 'the-code', redirect_uri: 'resgridunit://auth/callback', client_id: 'resgrid-desktop', code_verifier: expect.any(String) }); | ||
| // The verifier the provider receives is the one the challenge was made from. | ||
| expect(base64Url(crypto.createHash('sha256').update(form.code_verifier).digest())).toBe(page.searchParams.get('code_challenge')); | ||
| expect(f.sso.waiting).toBe(false); | ||
| }); | ||
|
|
||
| it('asks the provider to authenticate the member again for a shared installation, and only then', async () => { | ||
| const shared = fixture(); | ||
| const answer = shared.sso.oidc(AUTHORITY, 'resgrid-desktop', true); | ||
| for (let i = 0; i < 10 && shared.opened.length === 0; i++) { | ||
| await tick(); | ||
| } | ||
| const page = new URL(shared.opened[0]); | ||
| expect(page.searchParams.get('prompt')).toBe('login'); | ||
| expect(page.searchParams.get('max_age')).toBe('0'); | ||
| shared.sso.cancel(); | ||
| await answer; | ||
|
|
||
| const personal = fixture(); | ||
| const { answer: personalAnswer, page: personalPage } = await startOidc(personal); | ||
| expect(personalPage.searchParams.has('prompt')).toBe(false); | ||
| expect(personalPage.searchParams.has('max_age')).toBe(false); | ||
| personal.sso.cancel(); | ||
| await personalAnswer; | ||
| }); | ||
|
|
||
| it('ignores a link with another state, or no state, and keeps waiting for its own', async () => { | ||
| const f = fixture(); | ||
| const { answer, page } = await startOidc(f); | ||
|
|
||
| expect(f.sso.handleLink('resgridunit://auth/callback?code=forged&state=someone-elses')).toBe(true); | ||
| expect(f.sso.handleLink('resgridunit://auth/callback?code=forged')).toBe(true); | ||
| expect(f.sso.waiting).toBe(true); | ||
| expect(f.focus).not.toHaveBeenCalled(); | ||
|
|
||
| f.sso.handleLink(`resgridunit://auth/callback?code=real&state=${page.searchParams.get('state')}`); | ||
| await expect(answer).resolves.toEqual({ ok: true, idToken: 'idp.id.token' }); | ||
| expect(new URLSearchParams(f.requests.find((r) => r.url === TOKEN)!.init?.body).get('code')).toBe('real'); | ||
| }); | ||
|
|
||
| it("reports the provider's refusal: a denial, or another error with its code", async () => { | ||
| const denied = fixture(); | ||
| const first = await startOidc(denied); | ||
| denied.sso.handleLink(`resgridunit://auth/callback?error=access_denied&state=${first.page.searchParams.get('state')}`); | ||
| await expect(first.answer).resolves.toEqual({ ok: false, reason: 'denied', code: 'access_denied' }); | ||
|
|
||
| const broken = fixture(); | ||
| const second = await startOidc(broken); | ||
| broken.sso.handleLink(`resgridunit://auth/callback?error=invalid_scope&state=${second.page.searchParams.get('state')}`); | ||
| await expect(second.answer).resolves.toEqual({ ok: false, reason: 'failed', code: 'invalid_scope' }); | ||
| expect(broken.requests.some((r) => r.url === TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('fails when the return has no code, or the provider redeems it without an id_token or at all', async () => { | ||
| const noCode = fixture(); | ||
| const a = await startOidc(noCode); | ||
| noCode.sso.handleLink(`resgridunit://auth/callback?state=${a.page.searchParams.get('state')}`); | ||
| await expect(a.answer).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
|
|
||
| for (const tokenAnswer of [json({ access_token: 'at' }), json({ error: 'invalid_grant' }, 400), json({ id_token: '' }), json({ id_token: 'idp.id.token' }, 500)]) { | ||
| const f = fixture({ tokenAnswer }); | ||
| const { answer, page } = await startOidc(f); | ||
| f.sso.handleLink(`resgridunit://auth/callback?code=c&state=${page.searchParams.get('state')}`); | ||
| await expect(answer).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
| } | ||
| }); | ||
|
|
||
| it('opens nothing for an authority or provider pages that are not https, a missing client id, or a discovery failure', async () => { | ||
| const cases: [string, string, unknown][] = [ | ||
| ['http://login.example-idp.com/tenant', 'c', undefined], | ||
| ['javascript:alert(1)', 'c', undefined], | ||
| ['not a url', 'c', undefined], | ||
| [AUTHORITY, '', undefined], | ||
| [AUTHORITY, 'c', json({}, 500)], | ||
| [AUTHORITY, 'c', json({ authorization_endpoint: 'http://login.example-idp.com/authorize', token_endpoint: TOKEN })], | ||
| [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE, token_endpoint: 'http://login.example-idp.com/token' })], | ||
| [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE })], | ||
| // A provider page that answers with an error is no discovery document, whatever its body says. | ||
| [AUTHORITY, 'c', json({ authorization_endpoint: AUTHORIZE, token_endpoint: TOKEN }, 503)], | ||
| ]; | ||
| for (const [authority, clientId, discovery] of cases) { | ||
| const f = fixture(discovery === undefined ? {} : { discovery }); | ||
| await expect(f.sso.oidc(authority, clientId)).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
| expect(f.opened).toEqual([]); | ||
| if (discovery === undefined) { | ||
| // Refused before anything is fetched: no discovery document is read from an address that is not https. | ||
| expect(f.requests).toEqual([]); | ||
| } | ||
| expect(f.sso.waiting).toBe(false); | ||
| } | ||
| }); | ||
|
|
||
| it('reads the discovery document under the authority, whatever its trailing slash', async () => { | ||
| const f = fixture(); | ||
| const answer = f.sso.oidc(`${AUTHORITY}/`, 'c'); | ||
| await tick(); | ||
| expect(f.requests[0].url).toBe(DISCOVERY); | ||
| f.sso.cancel(); | ||
| await expect(answer).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('createLegacySso: SAML', () => { | ||
| it("opens the server's start page and answers with the relay's link back, for the page to check", async () => { | ||
| const f = fixture(); | ||
| const answer = f.sso.saml(START); | ||
| await tick(); | ||
| expect(f.opened).toEqual([START]); | ||
|
|
||
| const relayed = 'resgridunit://auth/callback?saml_response=saml-relay%3Aabc&department_token=enc&relay_state=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'; | ||
| expect(f.sso.handleLink(relayed)).toBe(true); | ||
| await expect(answer).resolves.toEqual({ ok: true, url: relayed }); | ||
| expect(f.focus).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('ignores a relay link with another RelayState or no SAML response', async () => { | ||
| const f = fixture(); | ||
| const answer = f.sso.saml(START); | ||
| await tick(); | ||
|
|
||
| f.sso.handleLink('resgridunit://auth/callback?saml_response=x&relay_state=unit.someone-elses-nonce-0000'); | ||
| f.sso.handleLink('resgridunit://auth/callback?relay_state=unit.3f2b8c1e-5d7a-4e9b-8a61-0c4d2e7f9b13'); | ||
| expect(f.sso.waiting).toBe(true); | ||
|
|
||
| f.sso.cancel(); | ||
| await expect(answer).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
| }); | ||
|
|
||
| it('opens no start page that is not https (loopback http aside) or that carries no RelayState', async () => { | ||
| for (const page of ['http://api.resgrid.com/api/v4/connect/saml-mobile-login?RelayState=unit.x', 'https://api.resgrid.com/api/v4/connect/saml-mobile-login', 'file:///etc/passwd?RelayState=x']) { | ||
| const f = fixture(); | ||
| await expect(f.sso.saml(page)).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
| expect(f.opened).toEqual([]); | ||
| } | ||
|
|
||
| const dev = fixture(); | ||
| const answer = dev.sso.saml('http://127.0.0.1:5098/api/v4/connect/saml-mobile-login?RelayState=unit.dev-nonce-0123456789'); | ||
| await tick(); | ||
| expect(dev.opened).toHaveLength(1); | ||
| dev.sso.cancel(); | ||
| await answer; | ||
| }); | ||
| }); | ||
|
|
||
| describe('createLegacySso: one sign-in at a time', () => { | ||
| it('a new sign-in replaces the waiting one, and cancel or the time limit ends it', async () => { | ||
| const f = fixture(); | ||
| const first = f.sso.saml(START); | ||
| await tick(); | ||
| const second = f.sso.saml(START); | ||
| await expect(first).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
|
|
||
| f.sso.cancel(); | ||
| await expect(second).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
| expect(f.sso.waiting).toBe(false); | ||
|
|
||
| const timed = fixture({ waitMs: 20 }); | ||
| await expect(timed.sso.saml(START)).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
| expect(timed.sso.waiting).toBe(false); | ||
| }); | ||
|
|
||
| it('fails when the browser cannot be opened', async () => { | ||
| const f = fixture({ | ||
| openExternal: async () => { | ||
| throw new Error('no browser'); | ||
| }, | ||
| }); | ||
| await expect(f.sso.saml(START)).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
| expect(f.sso.waiting).toBe(false); | ||
| }); | ||
|
|
||
| it("takes only this app's sign-in return: other links are left to the caller, and a return with nothing waiting is dropped", () => { | ||
| const f = fixture(); | ||
| expect(f.sso.handleLink('resgridunit://auth/callback?code=x&state=y')).toBe(true); | ||
| expect(f.sso.handleLink('resgridunit://calls/42')).toBe(false); | ||
| expect(f.sso.handleLink('resgridunit://other/callback?code=x&state=y')).toBe(false); | ||
| expect(f.sso.handleLink('resgridunit://auth/other?code=x&state=y')).toBe(false); | ||
| expect(f.sso.handleLink('resgriddispatch://auth/callback?code=x&state=y')).toBe(false); | ||
| expect(f.sso.handleLink('https://resgrid.com/auth/callback?code=x')).toBe(false); | ||
| expect(f.sso.handleLink('not a link')).toBe(false); | ||
| expect(f.focus).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("finds the return among a second instance's arguments, whatever the scheme's case", () => { | ||
| const f = fixture(); | ||
| expect(f.sso.linkIn(['C:\\Resgrid Unit\\Resgrid Unit.exe', '--allow-file-access', 'ResgridUnit://auth/callback?code=x&state=y'])).toBe('ResgridUnit://auth/callback?code=x&state=y'); | ||
| expect(f.sso.linkIn(['Resgrid Unit.exe', 'resgridunit://calls/42'])).toBeNull(); | ||
| expect(f.sso.linkIn(undefined)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('registerLegacySso', () => { | ||
| it('answers the renderer on three channels', async () => { | ||
| const handlers: Record<string, (...args: unknown[]) => unknown> = {}; | ||
| const ipcMain = { handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => (handlers[channel] = handler)) }; | ||
| const opened: string[] = []; | ||
| const sso = registerLegacySso(ipcMain, { | ||
| scheme: 'resgridunit', | ||
| openExternal: async (url: string) => { | ||
| opened.push(url); | ||
| }, | ||
| fetch: async () => json({}, 500), | ||
| }); | ||
|
|
||
| expect(Object.keys(handlers).sort()).toEqual(['legacy-sso:cancel', 'legacy-sso:oidc', 'legacy-sso:saml']); | ||
| await expect(handlers['legacy-sso:oidc']({}, AUTHORITY, 'c')).resolves.toEqual({ ok: false, reason: 'failed' }); | ||
|
|
||
| // Only a literal true from the page asks for reauthentication. | ||
| const oidc = jest.spyOn(sso, 'oidc'); | ||
| await handlers['legacy-sso:oidc']({}, AUTHORITY, 'c', 'yes'); | ||
| await handlers['legacy-sso:oidc']({}, AUTHORITY, 'c', true); | ||
| expect(oidc.mock.calls).toEqual([ | ||
| [AUTHORITY, 'c', false], | ||
| [AUTHORITY, 'c', true], | ||
| ]); | ||
|
|
||
| const saml = handlers['legacy-sso:saml']({}, START) as Promise<unknown>; | ||
| await tick(); | ||
| expect(opened).toEqual([START]); | ||
| expect(sso.waiting).toBe(true); | ||
| handlers['legacy-sso:cancel']({}); | ||
| await expect(saml).resolves.toEqual({ ok: false, reason: 'cancelled' }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Synchronous
createHash,Buffer.from, anddigestoperations run inside the asynchronousdigestfunction in__mocks__/expo-crypto.ts, blocking the event loop and making the async API misleading. Use the available asynchronous crypto and buffer APIs throughdigestWithAsyncCrypto.Kody rule violation: Use Awaitable Methods in Async Code
Prompt for LLM
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.