Skip to content

Commit 66a7fb5

Browse files
improve PAT login with encryption for edge cases.
1 parent fe892ac commit 66a7fb5

File tree

4 files changed

+462
-134
lines changed

4 files changed

+462
-134
lines changed

cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"bugs": "https://github.com/powersync-ja/cli/issues",
1010
"dependencies": {
11+
"@fastify/cors": "^10.0.0",
1112
"@inquirer/prompts": "^7.2.0",
1213
"@oclif/core": "^4",
1314
"@oclif/plugin-help": "^6",
@@ -21,6 +22,7 @@
2122
"@powersync/service-types": "^0.13.3",
2223
"jose": "^6.1.3",
2324
"lodash": "^4.17.23",
25+
"fastify": "^5.0.0",
2426
"open": "^11.0.0",
2527
"ora": "^9.0.0",
2628
"ts-codec": "^1.3.0",

cli/src/api/login-server.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import cors from '@fastify/cors';
2+
import { env } from '@powersync/cli-core';
3+
import Fastify from 'fastify';
4+
import { createDecipheriv, createPrivateKey, createPublicKey, generateKeyPairSync, privateDecrypt } from 'node:crypto';
5+
import open from 'open';
6+
7+
/** Hybrid encryption format from dashboard: [encrypted_cek (256)] [iv (12)] [ciphertext] [tag (16)]. */
8+
const RSA_ENCRYPTED_KEY_BYTES = 256;
9+
const IV_BYTES = 12;
10+
const GCM_TAG_BYTES = 16;
11+
12+
/** Decode base64 or base64url (URL-safe) to bytes. Accepts standard base64 or base64url from query params. */
13+
function decodeBase64Payload(payload: string): Buffer {
14+
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
15+
const pad = base64.length % 4;
16+
const padded = pad === 0 ? base64 : base64 + '='.repeat(4 - pad);
17+
return Buffer.from(padded, 'base64');
18+
}
19+
20+
/**
21+
* Decrypt a token encrypted by the dashboard with the hybrid scheme (RSA-OAEP-wrapped CEK + AES-256-GCM).
22+
* Payload is base64 or base64url: decoded = encrypted_cek (256 bytes) || iv (12) || ciphertext || auth_tag (16).
23+
* Uses Node crypto for RSA-OAEP and AES-GCM decryption.
24+
*/
25+
function decryptTokenFromDashboard(base64Payload: string, privateKeyPem: string): string {
26+
const raw = decodeBase64Payload(base64Payload.trim());
27+
if (raw.length < RSA_ENCRYPTED_KEY_BYTES + IV_BYTES + GCM_TAG_BYTES) {
28+
throw new Error('Payload too short');
29+
}
30+
const encryptedCek = raw.subarray(0, RSA_ENCRYPTED_KEY_BYTES);
31+
const iv = raw.subarray(RSA_ENCRYPTED_KEY_BYTES, RSA_ENCRYPTED_KEY_BYTES + IV_BYTES);
32+
const ciphertextWithTag = raw.subarray(RSA_ENCRYPTED_KEY_BYTES + IV_BYTES);
33+
const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - GCM_TAG_BYTES);
34+
const tag = ciphertextWithTag.subarray(-GCM_TAG_BYTES);
35+
36+
const keyObject = createPrivateKey({ key: privateKeyPem, format: 'pem' });
37+
const cek = privateDecrypt({ key: keyObject, oaepHash: 'sha256' }, encryptedCek);
38+
39+
const decipher = createDecipheriv('aes-256-gcm', cek, iv);
40+
decipher.setAuthTag(tag);
41+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
42+
}
43+
44+
/**
45+
* Server login handshake (asymmetric encryption)
46+
*
47+
* Flow:
48+
* 1. CLI starts a local HTTP server and generates an ephemeral RSA keypair.
49+
* 2. CLI opens the dashboard with a base64-encoded `request` param containing
50+
* { redirect_url, public_key }. The dashboard learns where to send the token
51+
* and how to encrypt it.
52+
* 3. User creates a token in the dashboard. The dashboard encrypts the token
53+
* with the CLI's public key and redirects the browser to redirect_url with
54+
* the encrypted token in the query string (GET), so the secret never appears in history.
55+
* 4. CLI receives GET /response?token=<base64> (see decryptTokenFromDashboard), decrypts it,
56+
* and stores the plaintext token.
57+
*
58+
* Why encryption:
59+
* In some cases the dashboard needs to hand the token back via a URL (e.g.
60+
* redirect or opening the CLI callback URL in the browser). If we sent the
61+
* token in the query string or fragment, it would appear in browser history,
62+
* logs, and referrers. Encrypting with the CLI's public key ensures only this
63+
* CLI instance can decrypt the token; the value that might appear in a URL or
64+
* in transit is ciphertext, not the secret.
65+
*/
66+
export async function startPATLoginServer(): Promise<{
67+
address: string;
68+
tokenPromise: Promise<string>;
69+
}> {
70+
const { publicKey: publicKeyPem, privateKey: privateKeyPem } = generateKeyPairSync('rsa', {
71+
modulusLength: 2048,
72+
publicKeyEncoding: { type: 'spki', format: 'pem' },
73+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
74+
});
75+
const publicKeyJwk = createPublicKey(publicKeyPem).export({ format: 'jwk' }) as JsonWebKey;
76+
77+
let settled = false;
78+
let resolveToken!: (value: string) => void;
79+
let rejectToken!: (err: Error) => void;
80+
const tokenPromise = new Promise<string>((resolve, reject) => {
81+
resolveToken = resolve;
82+
rejectToken = reject;
83+
});
84+
85+
const app = Fastify({ logger: false });
86+
const allowOrigin = env._PS_DASHBOARD_URL.replace(/\/$/, '');
87+
await app.register(cors, { origin: allowOrigin });
88+
89+
app.get<{ Querystring: { token?: string } }>('/response', async (request, reply) => {
90+
const rawToken = typeof request.query?.token === 'string' ? request.query.token.trim() : null;
91+
if (!rawToken) {
92+
await reply.status(400).send({ error: 'Missing or empty "token" query parameter' });
93+
if (!settled) {
94+
settled = true;
95+
rejectToken(new Error('Invalid request: GET /response must include a non-empty "token" query parameter'));
96+
}
97+
return;
98+
}
99+
let tokenValue: string;
100+
try {
101+
tokenValue = decryptTokenFromDashboard(rawToken, privateKeyPem);
102+
} catch {
103+
await reply.status(400).send({ error: 'Failed to decrypt token from dashboard' });
104+
if (!settled) {
105+
settled = true;
106+
rejectToken(new Error('Failed to decrypt token from dashboard'));
107+
}
108+
return;
109+
}
110+
if (settled) return;
111+
settled = true;
112+
await reply.status(200).send();
113+
resolveToken(tokenValue);
114+
await app.close();
115+
});
116+
117+
app.setErrorHandler((err, _request, reply) => {
118+
if (!settled) {
119+
settled = true;
120+
rejectToken(err instanceof Error ? err : new Error(String(err)));
121+
}
122+
void reply.status(500).send({ error: 'Internal server error' });
123+
});
124+
125+
const address = await app.listen({ port: 0, host: '127.0.0.1' });
126+
const responseUrl = `${address}/response`;
127+
const requestPayload: { redirect_url: string; public_key: JsonWebKey } = {
128+
redirect_url: responseUrl,
129+
public_key: publicKeyJwk
130+
};
131+
const requestBase64 = Buffer.from(JSON.stringify(requestPayload), 'utf-8').toString('base64');
132+
133+
open(`${env._PS_DASHBOARD_URL}/account/access-tokens/create?request=${encodeURIComponent(requestBase64)}`);
134+
135+
return { address, tokenPromise };
136+
}

cli/src/commands/login.ts

Lines changed: 4 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,7 @@
11
import { confirm, password } from '@inquirer/prompts';
22
import { ux } from '@oclif/core';
3-
import { createAccountsHubClient, env, PowerSyncCommand, Services } from '@powersync/cli-core';
4-
import { createServer } from 'node:http';
5-
import { AddressInfo } from 'node:net';
6-
import open from 'open';
7-
8-
async function startServer(): Promise<{
9-
address: string;
10-
tokenPromise: Promise<string>;
11-
}> {
12-
const server = createServer();
13-
14-
const address = await new Promise<string>((resolve, reject) => {
15-
server.once('error', (err) => {
16-
reject(err);
17-
});
18-
19-
server.listen(0, '127.0.0.1', () => {
20-
const addressInfo = server.address();
21-
if (typeof addressInfo !== 'object' || addressInfo === null || !('port' in addressInfo)) {
22-
reject(new Error('Failed to get address'));
23-
return;
24-
}
25-
const { port } = addressInfo as AddressInfo;
26-
resolve(`http://127.0.0.1:${port}`);
27-
// Dashboard will fetch() POST the token to this URL (no redirect; token in body).
28-
const baseResponseUrl = `http://127.0.0.1:${port}`;
29-
resolve(baseResponseUrl);
30-
});
31-
});
32-
33-
return {
34-
address,
35-
tokenPromise: new Promise<string>((resolve, reject) => {
36-
const responseUrl = `${address}/response`;
37-
open(`${env._PS_DASHBOARD_URL}/account/access-tokens/create?response_url=${encodeURIComponent(responseUrl)}`);
38-
39-
server.once('error', (err) => {
40-
reject(err);
41-
});
42-
43-
let settled = false;
44-
const rejectWith = (err: Error) => {
45-
if (settled) return;
46-
settled = true;
47-
server.close();
48-
reject(err);
49-
};
50-
51-
// Allow dashboard origin for CORS (fetch from dashboard to this callback)
52-
const allowOrigin = env._PS_DASHBOARD_URL.replace(/\/$/, '');
53-
const corsHeaders = {
54-
'Access-Control-Allow-Origin': allowOrigin,
55-
'Access-Control-Allow-Methods': 'POST, OPTIONS',
56-
'Access-Control-Allow-Headers': 'Content-Type'
57-
};
58-
const setCors = (res: import('node:http').ServerResponse) => {
59-
for (const [k, v] of Object.entries(corsHeaders)) res.setHeader(k, v);
60-
};
61-
62-
server.on('request', (req, res) => {
63-
const path = req.url?.split('?')[0] ?? '';
64-
if (req.method === 'OPTIONS' && path === '/response') {
65-
setCors(res);
66-
res.statusCode = 204;
67-
res.end();
68-
return;
69-
}
70-
if (req.method !== 'POST' || path !== '/response') {
71-
setCors(res);
72-
res.statusCode = 400;
73-
res.end();
74-
rejectWith(new Error('Invalid request: expected POST /response'));
75-
return;
76-
}
77-
setCors(res);
78-
const chunks: Buffer[] = [];
79-
req.on('data', (chunk) => chunks.push(chunk));
80-
req.on('end', () => {
81-
const contentType = req.headers['content-type'] ?? '';
82-
if (!contentType.includes('application/json')) {
83-
res.statusCode = 400;
84-
res.end();
85-
rejectWith(new Error('Invalid request: Content-Type must be application/json'));
86-
return;
87-
}
88-
let tokenValue: string | null = null;
89-
try {
90-
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { token?: string };
91-
tokenValue = typeof parsed?.token === 'string' ? parsed.token.trim() : null;
92-
} catch {
93-
tokenValue = null;
94-
}
95-
if (tokenValue) {
96-
if (settled) return;
97-
settled = true;
98-
res.statusCode = 200;
99-
res.end();
100-
resolve(tokenValue);
101-
server.close();
102-
} else {
103-
res.statusCode = 400;
104-
res.end();
105-
rejectWith(new Error('Invalid request: JSON body must include a non-empty "token" string'));
106-
}
107-
});
108-
});
109-
})
110-
};
111-
}
3+
import { createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core';
4+
import { startPATLoginServer } from '../api/login-server.js';
1125

1136
export default class Login extends PowerSyncCommand {
1147
static description =
@@ -136,12 +29,7 @@ export default class Login extends PowerSyncCommand {
13629

13730
const existingToken = await authentication.getToken();
13831
if (existingToken) {
139-
this.log(
140-
ux.colorize(
141-
'blue',
142-
'An existing token was found. This existing token has access to the following organizations:'
143-
)
144-
);
32+
this.log('An existing token was found. This existing token has access to the following organizations:');
14533
try {
14634
this.log(ux.colorize('gray', await listOrgs()));
14735
} catch (err) {
@@ -171,7 +59,7 @@ export default class Login extends PowerSyncCommand {
17159

17260
// Allows aborting the prompt if the server returns the token
17361
const abortPromptController = new AbortController();
174-
const serverResponse = openBrowser ? await startServer() : null;
62+
const serverResponse = openBrowser ? await startPATLoginServer() : null;
17563
if (serverResponse) {
17664
this.log(
17765
`Waiting on ${ux.colorize('blue', serverResponse.address)} for you to create a token in the dashboard...`

0 commit comments

Comments
 (0)