Skip to content

Commit 6ada82a

Browse files
committed
Allow auto-generating a token.
1 parent 256ee4e commit 6ada82a

File tree

8 files changed

+132
-12
lines changed

8 files changed

+132
-12
lines changed

pnpm-lock.yaml

Lines changed: 16 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test-client/README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Test Client
22

3-
This is a minimal client demonstrating direct usage of the sync api.
3+
This is a minimal client demonstrating direct usage of the HTTP stream sync api.
44

55
For a full implementation, see our client SDKs.
66

@@ -10,9 +10,25 @@ For a full implementation, see our client SDKs.
1010
# In project root
1111
pnpm install
1212
pnpm build:packages
13-
# Here
13+
# In this folder
1414
pnpm build
1515
node dist/bin.js fetch-operations --token <token> --endpoint http://localhost:8080
16+
17+
# More examples:
18+
19+
# If the endpoint is present in token aud field, it can be omitted from args:
20+
node dist/bin.js fetch-operations --token <token>
21+
22+
# If a local powersync.yaml is present with a configured HS256 key, this can be used:
23+
node dist/bin.js fetch-operations --config path/to/powersync.yaml --endpoint http://localhost:8080
24+
25+
# Without endpoint, it defaults to http://127.0.0.1:<port> from the config:
26+
node dist/bin.js fetch-operations --config path/to/powersync.yaml
27+
28+
# Use --sub to specify a user id in the generated token:
29+
node dist/bin.js fetch-operations --config path/to/powersync.yaml --sub test-user
1630
```
1731

1832
The script will normalize the data in each bucket to a single CLEAR operation, followed by the latest PUT operation for each row.
33+
34+
To get the raw operations instead, which may additionally include CLEAR, MOVE, REMOVE and duplicate PUT operations, use the `--raw` flag.

test-client/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
"type": "module",
1010
"scripts": {
1111
"fetch-operations": "tsc -b && node dist/bin.js fetch-operations",
12+
"generate-token": "tsc -b && node dist/bin.js generate-token",
1213
"build": "tsc -b",
1314
"clean": "rm -rf ./dist && tsc -b --clean"
1415
},
1516
"dependencies": {
1617
"@powersync/service-core": "workspace:*",
17-
"commander": "^12.0.0"
18+
"commander": "^12.0.0",
19+
"jose": "^4.15.1",
20+
"yaml": "^2.5.0"
1821
},
1922
"devDependencies": {
20-
"typescript": "^5.2.2",
21-
"@types/node": "18.11.11"
23+
"@types/node": "18.11.11",
24+
"typescript": "^5.2.2"
2225
}
2326
}

test-client/src/auth.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as jose from 'jose';
2+
import * as fs from 'node:fs/promises';
3+
import * as yaml from 'yaml';
4+
5+
export interface CredentialsOptions {
6+
token?: string;
7+
endpoint?: string;
8+
config?: string;
9+
sub?: string;
10+
}
11+
12+
export async function getCredentials(options: CredentialsOptions): Promise<{ endpoint: string; token: string }> {
13+
if (options.token != null) {
14+
if (options.endpoint != null) {
15+
return { token: options.token, endpoint: options.endpoint };
16+
} else {
17+
const parsed = jose.decodeJwt(options.token);
18+
const aud = Array.isArray(parsed.aud) ? parsed.aud[0] : parsed.aud;
19+
if (!(aud ?? '').startsWith('http')) {
20+
throw new Error(`Specify endpoint, or aud in the token`);
21+
}
22+
return {
23+
token: options.token,
24+
endpoint: aud!
25+
};
26+
}
27+
} else if (options.config != null) {
28+
const file = await fs.readFile(options.config, 'utf-8');
29+
const parsed = await yaml.parse(file);
30+
const keys = (parsed.client_auth?.jwks?.keys ?? []).filter((key: any) => key.alg == 'HS256');
31+
if (keys.length == 0) {
32+
throw new Error('No HS256 key found in the config');
33+
}
34+
35+
let endpoint = options.endpoint;
36+
if (endpoint == null) {
37+
endpoint = `http://127.0.0.1:${parsed.port ?? 8080}`;
38+
}
39+
40+
const aud = parsed.client_auth?.audience?.[0] ?? endpoint;
41+
42+
const rawKey = keys[0];
43+
const key = await jose.importJWK(rawKey);
44+
45+
const sub = options.sub ?? 'test_user';
46+
47+
const token = await new jose.SignJWT({})
48+
.setProtectedHeader({ alg: rawKey.alg, kid: rawKey.kid })
49+
.setSubject(sub)
50+
.setIssuedAt()
51+
.setIssuer('test-client')
52+
.setAudience(aud)
53+
.setExpirationTime('1h')
54+
.sign(key);
55+
56+
return { token, endpoint };
57+
} else {
58+
throw new Error(`Specify token or config path`);
59+
}
60+
}

test-client/src/bin.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
import { program } from 'commander';
22
import { getCheckpointData } from './client.js';
3+
import { getCredentials } from './auth.js';
4+
import * as jose from 'jose';
35

46
program
57
.command('fetch-operations')
6-
.option('-t, --token [token]')
7-
.option('-e, --endpoint [endpoint]')
8-
.option('--raw')
8+
.option('-t, --token [token]', 'JWT to use for authentication')
9+
.option('-e, --endpoint [endpoint]', 'endpoint URI')
10+
.option('-c, --config [config]', 'path to powersync.yaml, to auto-generate a token from a HS256 key')
11+
.option('-u, --sub [sub]', 'sub field for auto-generated token')
12+
.option('--raw', 'output operations as received, without normalizing')
913
.action(async (options) => {
10-
const data = await getCheckpointData({ endpoint: options.endpoint, token: options.token, raw: options.raw });
14+
const credentials = await getCredentials(options);
15+
const data = await getCheckpointData({ ...credentials, raw: options.raw });
1116
console.log(JSON.stringify(data, null, 2));
1217
});
1318

19+
program
20+
.command('generate-token')
21+
.description('Generate a JWT from for a given powersync.yaml config file')
22+
.option('-c, --config [config]', 'path to powersync.yaml')
23+
.option('-u, --sub [sub]', 'sub field for auto-generated token')
24+
.action(async (options) => {
25+
const credentials = await getCredentials(options);
26+
const decoded = await jose.decodeJwt(credentials.token);
27+
28+
console.error(`Payload:\n${JSON.stringify(decoded, null, 2)}\nToken:`);
29+
console.log(credentials.token);
30+
});
31+
1432
await program.parseAsync();

test-client/src/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export async function getCheckpointData(options: GetCheckpointOptions) {
1818
body: JSON.stringify({
1919
raw_data: true,
2020
include_checksum: true,
21+
// Client parameters can be specified here
2122
parameters: {}
2223
} satisfies types.StreamingSyncRequest)
2324
});
@@ -30,10 +31,12 @@ export async function getCheckpointData(options: GetCheckpointOptions) {
3031

3132
for await (let chunk of ndjsonStream<types.StreamingSyncLine>(response.body!)) {
3233
if (isStreamingSyncData(chunk)) {
34+
// Collect data
3335
data.push(chunk);
3436
} else if (isCheckpoint(chunk)) {
3537
checkpoint = chunk;
3638
} else if (isCheckpointComplete(chunk)) {
39+
// Stop on the first checkpoint_complete message.
3740
break;
3841
}
3942
}

test-client/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './client.js';
2+
export * from './ndjson.js';
3+
export * from './util.js';

test-client/src/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type * as types from '@powersync/service-core';
22

33
export type BucketData = Record<string, types.OplogEntry[]>;
44

5+
/**
6+
* Combine all chunks of received data, excluding any data after the checkpoint.
7+
*/
58
export function normalizeData(
69
checkpoint: types.StreamingSyncCheckpoint,
710
chunks: types.StreamingSyncData[],
@@ -39,6 +42,7 @@ export function isStreamingSyncData(line: types.StreamingSyncLine): line is type
3942
export function isCheckpointComplete(line: types.StreamingSyncLine): line is types.StreamingSyncCheckpointComplete {
4043
return (line as types.StreamingSyncCheckpointComplete).checkpoint_complete != null;
4144
}
45+
4246
export function isCheckpoint(line: types.StreamingSyncLine): line is types.StreamingSyncCheckpoint {
4347
return (line as types.StreamingSyncCheckpoint).checkpoint != null;
4448
}

0 commit comments

Comments
 (0)