snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
23
packages/frontend/apps/mobile-shared/package.json
Normal file
23
packages/frontend/apps/mobile-shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@affine/mobile-shared",
|
||||
"version": "0.27.5",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./auth/channel": "./src/auth/channel.ts",
|
||||
"./auth/endpoint": "./src/auth/endpoint.ts",
|
||||
"./auth/request": "./src/auth/request.ts",
|
||||
"./nbstore/optional": "./src/nbstore/optional.ts",
|
||||
"./nbstore/payload": "./src/nbstore/payload.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
"@capacitor/core": "^8.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
367
packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts
Normal file
367
packages/frontend/apps/mobile-shared/src/auth/auth.spec.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { MessagePortAuthProvider, serveAuthRequests } from './channel';
|
||||
import { canonicalAuthEndpoint, shouldRefreshAccessToken } from './endpoint';
|
||||
import { createAuthFetch, installAuthRequestProxy } from './request';
|
||||
|
||||
function stubXMLHttpRequest(completeOnSend = false) {
|
||||
const send = vi.fn();
|
||||
const abort = vi.fn();
|
||||
const instances = new Set<FakeXMLHttpRequest>();
|
||||
|
||||
class FakeXMLHttpRequest extends EventTarget {
|
||||
static readonly DONE = 4;
|
||||
readyState = 0;
|
||||
status = 0;
|
||||
responseType: XMLHttpRequestResponseType = '';
|
||||
response: unknown;
|
||||
responseText = '';
|
||||
timeout = 0;
|
||||
withCredentials = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
instances.add(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
setRequestHeader() {}
|
||||
|
||||
send(body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
send(body);
|
||||
if (completeOnSend) {
|
||||
this.readyState = FakeXMLHttpRequest.DONE;
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
abort();
|
||||
this.dispatchEvent(new Event('abort'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
|
||||
return {
|
||||
send,
|
||||
abort,
|
||||
respond(status: number, responseText: string) {
|
||||
const current = [...instances].at(-1);
|
||||
if (!current) throw new Error('No XMLHttpRequest instance');
|
||||
current.status = status;
|
||||
current.responseText = responseText;
|
||||
current.readyState = FakeXMLHttpRequest.DONE;
|
||||
current.dispatchEvent(new Event('readystatechange'));
|
||||
current.dispatchEvent(new Event('load'));
|
||||
current.dispatchEvent(new Event('loadend'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('canonicalAuthEndpoint', () => {
|
||||
test.each([
|
||||
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
|
||||
['https://affine.pro:443', 'https://affine.pro'],
|
||||
['http://localhost:80/path', 'http://localhost'],
|
||||
['http://localhost:8080/path', 'http://localhost:8080'],
|
||||
['capacitor://localhost/path', 'capacitor://localhost/path'],
|
||||
['invalid endpoint', 'invalid endpoint'],
|
||||
])('normalizes %s', (endpoint, expected) => {
|
||||
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRefreshAccessToken', () => {
|
||||
test.each(['ACCESS_TOKEN_EXPIRED', 'ACCESS_TOKEN_INVALID'])(
|
||||
'refreshes for %s',
|
||||
code => {
|
||||
expect(shouldRefreshAccessToken(code)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['INVALID_REFRESH_TOKEN', undefined, null])(
|
||||
'does not refresh for %s',
|
||||
code => {
|
||||
expect(shouldRefreshAccessToken(code)).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('auth request fetch', () => {
|
||||
test.each([
|
||||
['/socket.io', true],
|
||||
['/socket.io/', true],
|
||||
['/socket.io/?EIO=4&transport=polling', true],
|
||||
['/socket.ioevil', false],
|
||||
])('handles the socket auth boundary for %s', async (path, skipped) => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi.fn<typeof fetch>(async request => {
|
||||
expect((request as Request).headers.has('Authorization')).toBe(!skipped);
|
||||
return new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await createAuthFetch(provider, rawFetch)(`https://example.com${path}`);
|
||||
|
||||
expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
|
||||
});
|
||||
|
||||
test('injects the endpoint token', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 })
|
||||
);
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
await fetch('https://example.com/api/workspaces/1/blobs/1');
|
||||
|
||||
expect(provider.getValidAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(
|
||||
(rawFetch.mock.calls[0][0] as Request).headers.get('Authorization')
|
||||
).toBe('Bearer access-token');
|
||||
});
|
||||
|
||||
test('refreshes and replays an expired request once', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'expired-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
const response = await fetch('https://example.com/graphql');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(rawFetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(rawFetch.mock.calls[1][0] as Request).headers.get('Authorization')
|
||||
).toBe('Bearer refreshed-token');
|
||||
});
|
||||
|
||||
test('does not attach a token when the endpoint has no session', async () => {
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => null),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
const rawFetch = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 })
|
||||
);
|
||||
const fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
await fetch('https://cdn.example.com/presigned/blob');
|
||||
|
||||
expect(
|
||||
(rawFetch.mock.calls[0][0] as Request).headers.has('Authorization')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth request XMLHttpRequest', () => {
|
||||
test.each([
|
||||
['/socket.io', true],
|
||||
['/socket.io/', true],
|
||||
['/socket.io/?EIO=4&transport=polling', true],
|
||||
['/socket.ioevil', false],
|
||||
])('handles the socket auth boundary for %s', async (path, skipped) => {
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
installAuthRequestProxy(provider);
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('GET', `https://example.com${path}`);
|
||||
xhr.send();
|
||||
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
|
||||
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(provider.getValidAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledTimes(skipped ? 0 : 1);
|
||||
});
|
||||
|
||||
test('does not send after abort while waiting for a token', async () => {
|
||||
let resolveToken: (token: string | null) => void = () => {};
|
||||
const token = new Promise<string | null>(resolve => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(() => token),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
xhr.abort();
|
||||
resolveToken('access-token');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(xhrCalls.abort).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not send a stale body after reopening', async () => {
|
||||
const tokenResolvers: ((token: string | null) => void)[] = [];
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(
|
||||
() =>
|
||||
new Promise<string | null>(resolve => {
|
||||
tokenResolvers.push(resolve);
|
||||
})
|
||||
),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', 'https://example.com/first');
|
||||
xhr.send('first-body');
|
||||
xhr.open('POST', 'https://example.com/second');
|
||||
xhr.send('second-body');
|
||||
tokenResolvers[1](null);
|
||||
tokenResolvers[0](null);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(xhrCalls.send).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).toHaveBeenCalledWith('second-body');
|
||||
});
|
||||
|
||||
test('uses the native lifecycle when token lookup fails', async () => {
|
||||
const xhrCalls = stubXMLHttpRequest(true);
|
||||
installAuthRequestProxy({
|
||||
getValidAccessToken: vi.fn(async () => {
|
||||
throw new Error('token lookup failed');
|
||||
}),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
const loadend = vi.fn();
|
||||
xhr.addEventListener('loadend', loadend);
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledWith('body'));
|
||||
|
||||
expect(xhr.readyState).toBe(XMLHttpRequest.DONE);
|
||||
expect(loadend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('preserves abort lifecycle while waiting to replay', async () => {
|
||||
let resolveRefresh: (token: string) => void = () => {};
|
||||
const refresh = new Promise<string>(resolve => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const xhrCalls = stubXMLHttpRequest();
|
||||
const provider = {
|
||||
getValidAccessToken: vi.fn(async () => null),
|
||||
refreshAccessToken: vi.fn(() => refresh),
|
||||
};
|
||||
installAuthRequestProxy(provider);
|
||||
const xhr = new XMLHttpRequest();
|
||||
const loadend = vi.fn();
|
||||
xhr.addEventListener('loadend', loadend);
|
||||
|
||||
xhr.open('POST', 'https://example.com/graphql');
|
||||
xhr.send('body');
|
||||
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
|
||||
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
|
||||
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
|
||||
|
||||
xhr.abort();
|
||||
resolveRefresh('refreshed-token');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(loadend).toHaveBeenCalledOnce();
|
||||
expect(xhrCalls.send).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth message channel', () => {
|
||||
test('serves get-valid and refresh operations', async () => {
|
||||
const channel = new MessageChannel();
|
||||
const nativeProvider = {
|
||||
getValidAccessToken: vi.fn(async () => 'access-token'),
|
||||
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
|
||||
};
|
||||
serveAuthRequests(channel.port1, nativeProvider);
|
||||
const workerProvider = new MessagePortAuthProvider();
|
||||
workerProvider.setPort(channel.port2);
|
||||
|
||||
await expect(
|
||||
workerProvider.getValidAccessToken('https://example.com')
|
||||
).resolves.toBe('access-token');
|
||||
await expect(
|
||||
workerProvider.refreshAccessToken('https://example.com')
|
||||
).resolves.toBe('refreshed-token');
|
||||
|
||||
expect(nativeProvider.getValidAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(nativeProvider.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'https://example.com'
|
||||
);
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
|
||||
test('maps terminal get-valid errors to an empty session', async () => {
|
||||
const channel = new MessageChannel();
|
||||
const error = Object.assign(new Error('expired'), {
|
||||
code: 'AUTH_SESSION_EXPIRED',
|
||||
});
|
||||
serveAuthRequests(channel.port1, {
|
||||
getValidAccessToken: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
refreshAccessToken: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
});
|
||||
const workerProvider = new MessagePortAuthProvider();
|
||||
workerProvider.setPort(channel.port2);
|
||||
|
||||
await expect(
|
||||
workerProvider.getValidAccessToken('https://example.com')
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
workerProvider.refreshAccessToken('https://example.com')
|
||||
).rejects.toThrow('AUTH_SESSION_EXPIRED');
|
||||
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
});
|
||||
137
packages/frontend/apps/mobile-shared/src/auth/channel.ts
Normal file
137
packages/frontend/apps/mobile-shared/src/auth/channel.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { AuthRequestProvider } from './request';
|
||||
|
||||
type AuthOperation = 'get-valid' | 'refresh';
|
||||
|
||||
type AuthRequest = {
|
||||
id: string;
|
||||
operation: AuthOperation;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
type AuthResponse = {
|
||||
id: string;
|
||||
token?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const terminalAuthErrors = new Set([
|
||||
'ACCESS_TOKEN_INVALID',
|
||||
'AUTH_SESSION_EXPIRED',
|
||||
'AUTH_SESSION_REVOKED',
|
||||
'REFRESH_TOKEN_INVALID',
|
||||
'REFRESH_TOKEN_REUSED',
|
||||
'UNSUPPORTED_CLIENT_VERSION',
|
||||
'AUTH_SESSION_EMPTY',
|
||||
]);
|
||||
|
||||
const temporaryAuthError = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
return typeof error === 'object' &&
|
||||
error &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: temporaryAuthError;
|
||||
}
|
||||
|
||||
export function serveAuthRequests(
|
||||
port: MessagePort,
|
||||
provider: AuthRequestProvider
|
||||
) {
|
||||
port.addEventListener('message', event => {
|
||||
const { id, operation, endpoint } = event.data as Partial<AuthRequest>;
|
||||
if (
|
||||
!id ||
|
||||
(operation !== 'get-valid' && operation !== 'refresh') ||
|
||||
!endpoint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request =
|
||||
operation === 'refresh'
|
||||
? provider.refreshAccessToken(endpoint)
|
||||
: provider.getValidAccessToken(endpoint);
|
||||
request.then(
|
||||
token => port.postMessage({ id, token } satisfies AuthResponse),
|
||||
error =>
|
||||
port.postMessage({
|
||||
id,
|
||||
error: errorCode(error),
|
||||
} satisfies AuthResponse)
|
||||
);
|
||||
});
|
||||
port.start();
|
||||
}
|
||||
|
||||
export class MessagePortAuthProvider implements AuthRequestProvider {
|
||||
private port?: MessagePort;
|
||||
private nextRequestId = 0;
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
setPort(port: MessagePort) {
|
||||
this.port = port;
|
||||
port.addEventListener('message', event => {
|
||||
const { id, token, error } = event.data as Partial<AuthResponse>;
|
||||
if (!id) return;
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
|
||||
if (error) {
|
||||
pending.reject(new Error(error));
|
||||
} else {
|
||||
pending.resolve(token ?? null);
|
||||
}
|
||||
this.pending.delete(id);
|
||||
});
|
||||
port.start();
|
||||
}
|
||||
|
||||
async getValidAccessToken(endpoint: string) {
|
||||
try {
|
||||
return await this.request('get-valid', endpoint);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && terminalAuthErrors.has(error.message)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAccessToken(endpoint: string) {
|
||||
const token = await this.request('refresh', endpoint);
|
||||
if (!token) throw new Error(temporaryAuthError);
|
||||
return token;
|
||||
}
|
||||
|
||||
private request(operation: AuthOperation, endpoint: string) {
|
||||
const port = this.port;
|
||||
if (!port) return Promise.reject(new Error(temporaryAuthError));
|
||||
|
||||
const id = String(++this.nextRequestId);
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(temporaryAuthError));
|
||||
}, 5000);
|
||||
this.pending.set(id, {
|
||||
resolve: token => {
|
||||
clearTimeout(timeout);
|
||||
resolve(token);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
port.postMessage({ id, operation, endpoint } satisfies AuthRequest);
|
||||
});
|
||||
}
|
||||
}
|
||||
14
packages/frontend/apps/mobile-shared/src/auth/endpoint.ts
Normal file
14
packages/frontend/apps/mobile-shared/src/auth/endpoint.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function canonicalAuthEndpoint(endpoint: string) {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
? url.origin
|
||||
: endpoint;
|
||||
} catch {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshAccessToken(code: unknown) {
|
||||
return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID';
|
||||
}
|
||||
243
packages/frontend/apps/mobile-shared/src/auth/request.ts
Normal file
243
packages/frontend/apps/mobile-shared/src/auth/request.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { shouldRefreshAccessToken } from './endpoint';
|
||||
|
||||
export interface AuthRequestProvider {
|
||||
getValidAccessToken(endpoint: string): Promise<string | null>;
|
||||
refreshAccessToken(endpoint: string): Promise<string>;
|
||||
}
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
try {
|
||||
const parsed = new URL(
|
||||
url,
|
||||
globalThis.location?.origin ?? 'http://localhost'
|
||||
);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.origin
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipStoredAuthToken(url: string | URL) {
|
||||
try {
|
||||
const { pathname } = new URL(
|
||||
url,
|
||||
globalThis.location?.origin ?? 'http://localhost'
|
||||
);
|
||||
if (pathname === '/socket.io' || pathname.startsWith('/socket.io/')) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
'/api/auth/captcha',
|
||||
'/api/auth/magic-link',
|
||||
'/api/auth/open-app/sign-in',
|
||||
'/api/auth/preflight',
|
||||
'/api/auth/session/exchange',
|
||||
'/api/auth/sign-in',
|
||||
'/api/oauth/callback',
|
||||
'/api/oauth/preflight',
|
||||
].includes(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthFetch(
|
||||
provider: AuthRequestProvider,
|
||||
rawFetch: typeof globalThis.fetch
|
||||
) {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const retry = request.clone();
|
||||
const skipStoredAuth = shouldSkipStoredAuthToken(request.url);
|
||||
const endpoint = skipStoredAuth ? null : authEndpointForUrl(request.url);
|
||||
const token = endpoint
|
||||
? await provider.getValidAccessToken(endpoint)
|
||||
: null;
|
||||
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await rawFetch(request);
|
||||
if (response.status !== 401 || !endpoint) return response;
|
||||
|
||||
const body = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (!shouldRefreshAccessToken(body?.code)) return response;
|
||||
|
||||
const refreshed = await provider.refreshAccessToken(endpoint);
|
||||
retry.headers.set('Authorization', `Bearer ${refreshed}`);
|
||||
return rawFetch(retry);
|
||||
};
|
||||
}
|
||||
|
||||
export function installAuthRequestProxy(provider: AuthRequestProvider) {
|
||||
const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = createAuthFetch(provider, rawFetch);
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
private request:
|
||||
| {
|
||||
method: string;
|
||||
url: string | URL;
|
||||
async: boolean;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
private readonly headers = new Map<string, string>();
|
||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
||||
private replaying = false;
|
||||
private hasReplayed = false;
|
||||
private skipStoredAuth = false;
|
||||
private sendVersion = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const suppressExpiredResponse = (event: Event) => {
|
||||
if (this.replaying) event.stopImmediatePropagation();
|
||||
};
|
||||
this.addEventListener('load', suppressExpiredResponse, true);
|
||||
this.addEventListener('loadend', suppressExpiredResponse, true);
|
||||
this.addEventListener(
|
||||
'readystatechange',
|
||||
event => {
|
||||
if (
|
||||
this.readyState !== rawXMLHttpRequest.DONE ||
|
||||
this.status !== 401 ||
|
||||
this.replaying ||
|
||||
this.hasReplayed ||
|
||||
this.skipStoredAuth ||
|
||||
!this.request?.async
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let code: unknown;
|
||||
try {
|
||||
code =
|
||||
this.responseType === 'json'
|
||||
? this.response?.code
|
||||
: JSON.parse(this.responseText)?.code;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!shouldRefreshAccessToken(code)) return;
|
||||
event.stopImmediatePropagation();
|
||||
this.replaying = true;
|
||||
this.hasReplayed = true;
|
||||
this.replayWithFreshToken().catch(() => {});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async: boolean = true,
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
this.sendVersion++;
|
||||
this.request = { method, url, async, username, password };
|
||||
this.headers.clear();
|
||||
this.requestBody = undefined;
|
||||
this.replaying = false;
|
||||
this.hasReplayed = false;
|
||||
this.skipStoredAuth = shouldSkipStoredAuthToken(url.toString());
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
url,
|
||||
async,
|
||||
username ?? undefined,
|
||||
password ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
override setRequestHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
this.requestBody = body;
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const targetUrl = requestUrl ?? globalThis.location.href;
|
||||
this.skipStoredAuth = shouldSkipStoredAuthToken(targetUrl);
|
||||
const endpoint = this.skipStoredAuth
|
||||
? null
|
||||
: authEndpointForUrl(targetUrl);
|
||||
const sendVersion = this.sendVersion;
|
||||
|
||||
const sendWithToken = (token: string | null) => {
|
||||
if (sendVersion !== this.sendVersion) return;
|
||||
if (token) {
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
super.send(body);
|
||||
};
|
||||
|
||||
(endpoint
|
||||
? provider.getValidAccessToken(endpoint)
|
||||
: Promise.resolve(null)
|
||||
).then(sendWithToken, () => sendWithToken(null));
|
||||
}
|
||||
|
||||
override abort(): void {
|
||||
this.sendVersion++;
|
||||
this.replaying = false;
|
||||
super.abort();
|
||||
}
|
||||
|
||||
private async replayWithFreshToken() {
|
||||
const request = this.request;
|
||||
if (!request || this.skipStoredAuth) return this.failReplay();
|
||||
const endpoint = authEndpointForUrl(request.url);
|
||||
if (!endpoint) return this.failReplay();
|
||||
const sendVersion = this.sendVersion;
|
||||
try {
|
||||
const token = await provider.refreshAccessToken(endpoint);
|
||||
if (sendVersion !== this.sendVersion) return;
|
||||
const responseType = this.responseType;
|
||||
const timeout = this.timeout;
|
||||
const withCredentials = this.withCredentials;
|
||||
super.open(
|
||||
request.method,
|
||||
request.url,
|
||||
true,
|
||||
request.username ?? undefined,
|
||||
request.password ?? undefined
|
||||
);
|
||||
this.replaying = false;
|
||||
this.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase() !== 'authorization') {
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
});
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
this.responseType = responseType;
|
||||
this.timeout = timeout;
|
||||
this.withCredentials = withCredentials;
|
||||
super.send(this.requestBody);
|
||||
} catch {
|
||||
if (sendVersion === this.sendVersion) {
|
||||
this.failReplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failReplay() {
|
||||
this.replaying = false;
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
};
|
||||
}
|
||||
5
packages/frontend/apps/mobile-shared/src/index.ts
Normal file
5
packages/frontend/apps/mobile-shared/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './auth/channel';
|
||||
export * from './auth/endpoint';
|
||||
export * from './auth/request';
|
||||
export * from './nbstore/optional';
|
||||
export * from './nbstore/payload';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeNativeOptional } from './optional';
|
||||
|
||||
describe('normalizeNativeOptional', () => {
|
||||
it.each([
|
||||
['string', 'summary', 'summary'],
|
||||
['null', null, null],
|
||||
['missing key', undefined, null],
|
||||
['wrong type', 42, 42],
|
||||
])('normalizes %s', (_, input, expected) => {
|
||||
expect(normalizeNativeOptional(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeNativeOptional<T>(value: T | null | undefined) {
|
||||
return value ?? null;
|
||||
}
|
||||
145
packages/frontend/apps/mobile-shared/src/nbstore/payload.spec.ts
Normal file
145
packages/frontend/apps/mobile-shared/src/nbstore/payload.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockBase64ToUint8Array, mockConvertFileSrc } = vi.hoisted(() => ({
|
||||
mockBase64ToUint8Array: vi.fn((data: string) =>
|
||||
Uint8Array.from(data.split('').map(char => char.charCodeAt(0)))
|
||||
),
|
||||
mockConvertFileSrc: vi.fn((path: string) => `capacitor://localhost${path}`),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workspace-engine', () => ({
|
||||
base64ToUint8Array: mockBase64ToUint8Array,
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: {
|
||||
convertFileSrc: mockConvertFileSrc,
|
||||
},
|
||||
}));
|
||||
|
||||
import { decodePayload, MOBILE_BLOB_FILE_PREFIX } from './payload';
|
||||
|
||||
describe('decodePayload', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
mockBase64ToUint8Array.mockClear();
|
||||
mockConvertFileSrc.mockClear();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('decodes inline base64 payloads without file IO', async () => {
|
||||
const decoded = await decodePayload('ZGF0YQ==', MOBILE_BLOB_FILE_PREFIX);
|
||||
expect(decoded).toEqual(Uint8Array.from([90, 71, 70, 48, 89, 81, 61, 61]));
|
||||
expect(mockBase64ToUint8Array).toHaveBeenCalledWith('ZGF0YQ==');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads valid cache file tokens', async () => {
|
||||
const expected = Uint8Array.from([1, 2, 3, 4]);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const decoded = await decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
);
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads valid android cache file tokens', async () => {
|
||||
const expected = Uint8Array.from([4, 3, 2, 1]);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const path =
|
||||
'/data/user/0/com.affine.app/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const decoded = await decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
);
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects suffix-only paths outside expected cache shape', async () => {
|
||||
const path =
|
||||
'/attacker/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects random cache roots', async () => {
|
||||
const path =
|
||||
'/random/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects encoded traversal segments', async () => {
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/%2E%2E/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries once with refreshed payload when token read fails', async () => {
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const payload = `${MOBILE_BLOB_FILE_PREFIX}${path}`;
|
||||
const expected = Uint8Array.from([9, 8, 7]);
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const reloadedPayload = vi.fn(async () => payload);
|
||||
const decoded = await decodePayload(payload, MOBILE_BLOB_FILE_PREFIX, {
|
||||
onTokenReadFailure: reloadedPayload,
|
||||
});
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(reloadedPayload).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
167
packages/frontend/apps/mobile-shared/src/nbstore/payload.ts
Normal file
167
packages/frontend/apps/mobile-shared/src/nbstore/payload.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { base64ToUint8Array } from '@affine/core/modules/workspace-engine';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:';
|
||||
export const MOBILE_PAYLOAD_INLINE_THRESHOLD_BYTES = 1024 * 1024;
|
||||
const MOBILE_PAYLOAD_CACHE_DIR = 'nbstore-blob-cache';
|
||||
const MOBILE_PAYLOAD_BUCKET_PATTERN = /^[0-9a-f]{16}$/;
|
||||
const MOBILE_PAYLOAD_FILE_PATTERN = /^[0-9a-f]{16}\.blob$/;
|
||||
const MOBILE_PAYLOAD_PARENT_DIRS = new Set(['cache', 'Caches', 'T', 'tmp']);
|
||||
const MOBILE_ANDROID_PACKAGE_PATTERN =
|
||||
/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
||||
|
||||
function normalizeTokenFilePath(rawPath: string): string {
|
||||
const trimmedPath = rawPath.trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error('Invalid mobile payload token: empty file path');
|
||||
}
|
||||
|
||||
return trimmedPath.startsWith('file://')
|
||||
? trimmedPath
|
||||
: `file://${trimmedPath}`;
|
||||
}
|
||||
|
||||
function assertMobileCachePath(fileUrl: string): void {
|
||||
let pathname: string;
|
||||
try {
|
||||
const parsedUrl = new URL(fileUrl);
|
||||
if (parsedUrl.protocol !== 'file:') {
|
||||
throw new Error('unexpected protocol');
|
||||
}
|
||||
pathname = parsedUrl.pathname;
|
||||
} catch {
|
||||
throw new Error('Invalid mobile payload token: malformed file URL');
|
||||
}
|
||||
|
||||
let decodedSegments: string[];
|
||||
try {
|
||||
decodedSegments = pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(segment => {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
if (
|
||||
!decoded ||
|
||||
decoded === '.' ||
|
||||
decoded === '..' ||
|
||||
decoded.includes('/') ||
|
||||
decoded.includes('\\')
|
||||
) {
|
||||
throw new Error('path traversal');
|
||||
}
|
||||
return decoded;
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = decodedSegments.at(-1);
|
||||
const bucket = decodedSegments.at(-2);
|
||||
const cacheDir = decodedSegments.at(-3);
|
||||
const parentDir = decodedSegments.at(-4);
|
||||
const cacheParent = decodedSegments.at(-5);
|
||||
|
||||
if (
|
||||
!fileName ||
|
||||
!bucket ||
|
||||
!cacheDir ||
|
||||
!parentDir ||
|
||||
cacheDir !== MOBILE_PAYLOAD_CACHE_DIR ||
|
||||
!MOBILE_PAYLOAD_BUCKET_PATTERN.test(bucket) ||
|
||||
!MOBILE_PAYLOAD_FILE_PATTERN.test(fileName) ||
|
||||
!MOBILE_PAYLOAD_PARENT_DIRS.has(parentDir) ||
|
||||
!isAllowedCacheParent(decodedSegments, parentDir, cacheParent)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedCacheParent(
|
||||
parts: string[],
|
||||
parentDir: string,
|
||||
cacheParent: string | undefined
|
||||
): boolean {
|
||||
if (parentDir === 'Caches') {
|
||||
return cacheParent === 'Library' && ['var', 'private'].includes(parts[0]);
|
||||
}
|
||||
|
||||
if (parentDir === 'cache') {
|
||||
if (parts[0] !== 'data' || !cacheParent) {
|
||||
return false;
|
||||
}
|
||||
if (!MOBILE_ANDROID_PACKAGE_PATTERN.test(cacheParent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parts[1] === 'data') {
|
||||
return true;
|
||||
}
|
||||
if (parts[1] === 'user') {
|
||||
return !!parts[2] && /^[0-9]+$/.test(parts[2]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parentDir === 'tmp') {
|
||||
return (
|
||||
parts[0] === 'tmp' ||
|
||||
(parts[0] === 'private' && parts[1] === 'var' && parts[2] === 'tmp')
|
||||
);
|
||||
}
|
||||
|
||||
if (parentDir === 'T') {
|
||||
return parts[0] === 'var' && parts[1] === 'folders';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readTokenPayload(filePath: string): Promise<Uint8Array> {
|
||||
const response = await fetch(Capacitor.convertFileSrc(filePath));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read mobile payload file: ${filePath} (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export interface DecodePayloadOptions {
|
||||
onTokenReadFailure?: (error: Error) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
export async function decodePayload(
|
||||
data: string,
|
||||
prefix: string,
|
||||
options?: DecodePayloadOptions
|
||||
): Promise<Uint8Array> {
|
||||
if (!data.startsWith(prefix)) {
|
||||
return base64ToUint8Array(data);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeTokenFilePath(data.slice(prefix.length));
|
||||
assertMobileCachePath(normalizedPath);
|
||||
|
||||
try {
|
||||
return await readTokenPayload(normalizedPath);
|
||||
} catch (error) {
|
||||
const reloadPayload = options?.onTokenReadFailure;
|
||||
if (!reloadPayload) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const refreshedPayload = await reloadPayload(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
if (!refreshedPayload) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return decodePayload(refreshedPayload, prefix);
|
||||
}
|
||||
}
|
||||
10
packages/frontend/apps/mobile-shared/tsconfig.json
Normal file
10
packages/frontend/apps/mobile-shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../../core" }]
|
||||
}
|
||||
Reference in New Issue
Block a user