@tempoxyz/example-auth-cli 0.0.0-pr-5.sha-cd892ce
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Credentials.js +81 -0
- package/dist/TempoAccount.js +39 -0
- package/dist/commands/fund.js +18 -0
- package/dist/commands/login.js +68 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/send.js +36 -0
- package/dist/commands/whoami.js +19 -0
- package/dist/index.js +20 -0
- package/dist/viem.config.js +7 -0
- package/package.json +29 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tokens and access key the CLI keeps between runs, in a file only the current user can read.
|
|
3
|
+
* Access tokens expire after minutes, so `active` refreshes them, and refresh tokens rotate on
|
|
4
|
+
* every refresh.
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import * as Fs from 'node:fs/promises';
|
|
8
|
+
import * as Os from 'node:os';
|
|
9
|
+
import * as Path from 'node:path';
|
|
10
|
+
import { z } from 'incur';
|
|
11
|
+
import { KeyAuthorization } from 'ox/tempo';
|
|
12
|
+
/** Where Tempo Auth runs, the CLI's Connect app, and where tokens live. */
|
|
13
|
+
export const env = z.object({
|
|
14
|
+
TEMPO_AUTH_CREDENTIALS: z
|
|
15
|
+
.string()
|
|
16
|
+
.default(Path.join(Os.homedir(), '.config', 'tempo-auth', 'credentials.json')),
|
|
17
|
+
TEMPO_AUTH_URL: z.string().default('http://localhost:5173'),
|
|
18
|
+
// The `tempo-cli` Connect app in the staging environment, which refreshes with its client ID alone.
|
|
19
|
+
CLI_CLIENT_ID: z.string().default('client_01M4N1K6HAT9QWVT2V59NNY48J'),
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Returns the saved tokens, refreshing the access token when it has expired, or `undefined`.
|
|
23
|
+
* Credentials without an access key, from an older CLI, or whose key has expired count as signed
|
|
24
|
+
* out, so `login` authorizes a new key.
|
|
25
|
+
*/
|
|
26
|
+
export async function active(env) {
|
|
27
|
+
const saved = await Fs.readFile(env.TEMPO_AUTH_CREDENTIALS, 'utf8')
|
|
28
|
+
// An older CLI saved no `key`, which this check catches at runtime.
|
|
29
|
+
.then((text) => JSON.parse(text))
|
|
30
|
+
.catch(() => undefined);
|
|
31
|
+
if (!saved?.key || expired(saved.key))
|
|
32
|
+
return undefined;
|
|
33
|
+
if (saved.expiresAt - 30_000 > Date.now())
|
|
34
|
+
return saved;
|
|
35
|
+
const response = await fetch(new URL('/oauth2/token', env.TEMPO_AUTH_URL), {
|
|
36
|
+
body: new URLSearchParams({
|
|
37
|
+
client_id: env.CLI_CLIENT_ID,
|
|
38
|
+
grant_type: 'refresh_token',
|
|
39
|
+
refresh_token: saved.refreshToken,
|
|
40
|
+
}),
|
|
41
|
+
method: 'POST',
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
return undefined;
|
|
45
|
+
return save(env, {
|
|
46
|
+
key: saved.key,
|
|
47
|
+
tokens: (await response.json()),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Reads the signed-in person's email from Tempo Auth's userinfo endpoint. */
|
|
51
|
+
export async function email(env, options) {
|
|
52
|
+
const response = await fetch(new URL('/oauth2/userinfo', env.TEMPO_AUTH_URL), {
|
|
53
|
+
headers: { authorization: `Bearer ${options.credentials.accessToken}` },
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new Error(`userinfo answered ${response.status}.`);
|
|
57
|
+
return (await response.json()).email;
|
|
58
|
+
}
|
|
59
|
+
/** Whether the key's authorization has expired. */
|
|
60
|
+
function expired(key) {
|
|
61
|
+
const { expiry } = KeyAuthorization.fromRpc(key.keyAuthorization);
|
|
62
|
+
return Boolean(expiry) && expiry * 1000 <= Date.now();
|
|
63
|
+
}
|
|
64
|
+
/** Forgets the saved tokens. */
|
|
65
|
+
export async function remove(env) {
|
|
66
|
+
await Fs.rm(env.TEMPO_AUTH_CREDENTIALS, { force: true });
|
|
67
|
+
}
|
|
68
|
+
/** Saves a token response and the access key, readable only by the current user. */
|
|
69
|
+
export async function save(env, options) {
|
|
70
|
+
const credentials = {
|
|
71
|
+
accessToken: options.tokens.access_token,
|
|
72
|
+
expiresAt: Date.now() + options.tokens.expires_in * 1000,
|
|
73
|
+
key: options.key,
|
|
74
|
+
refreshToken: options.tokens.refresh_token,
|
|
75
|
+
};
|
|
76
|
+
await Fs.mkdir(Path.dirname(env.TEMPO_AUTH_CREDENTIALS), { recursive: true });
|
|
77
|
+
await Fs.writeFile(env.TEMPO_AUTH_CREDENTIALS, JSON.stringify(credentials), {
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
return credentials;
|
|
81
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's access key on a person's Tempo Account. The app holds the key, asks Tempo Auth for a
|
|
3
|
+
* key authorization for it, and sends with it on Tempo testnet. The first transaction carries the
|
|
4
|
+
* authorization, which registers the key.
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { Hex } from 'ox';
|
|
8
|
+
import { KeyAuthorization } from 'ox/tempo';
|
|
9
|
+
import { Account, Addresses, KeyAuthorizationManager } from 'viem/tempo';
|
|
10
|
+
import { client } from './viem.config.js';
|
|
11
|
+
/**
|
|
12
|
+
* The key authorization request for `accessKey`, in the Accounts SDK's `wallet_connect` format: up
|
|
13
|
+
* to 10 pathUSD in transfers, for a day.
|
|
14
|
+
*/
|
|
15
|
+
export function getKeyAuthorizationRequest(accessKey) {
|
|
16
|
+
return {
|
|
17
|
+
address: accessKey.address,
|
|
18
|
+
chainId: Hex.fromNumber(client.chain.id),
|
|
19
|
+
expiry: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
|
|
20
|
+
keyType: 'secp256k1',
|
|
21
|
+
limits: [{ limit: Hex.fromNumber(10000000n), token: Addresses.pathUsd }],
|
|
22
|
+
scopes: [
|
|
23
|
+
{ address: Addresses.pathUsd, selector: 'transfer(address,uint256)' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The key as a viem account acting for the Tempo Account. viem attaches the authorization until the chain registers the key. */
|
|
28
|
+
export function from(key) {
|
|
29
|
+
const keyAuthorizationManager = KeyAuthorizationManager.memory();
|
|
30
|
+
keyAuthorizationManager.set({
|
|
31
|
+
accessKey: Account.fromSecp256k1(key.privateKey).address,
|
|
32
|
+
address: key.account,
|
|
33
|
+
chainId: client.chain.id,
|
|
34
|
+
}, KeyAuthorization.fromRpc(key.keyAuthorization));
|
|
35
|
+
return Account.fromSecp256k1(key.privateKey, {
|
|
36
|
+
access: key.account,
|
|
37
|
+
keyAuthorizationManager,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Funds the person's Tempo Account from the Tempo testnet faucet, including pathUSD for fees. @module */
|
|
2
|
+
import { Cli } from 'incur';
|
|
3
|
+
import * as Credentials from '../Credentials.js';
|
|
4
|
+
import { client } from '../viem.config.js';
|
|
5
|
+
export default Cli.command({
|
|
6
|
+
description: 'Fund your Tempo Account with testnet tokens',
|
|
7
|
+
env: Credentials.env,
|
|
8
|
+
async run(c) {
|
|
9
|
+
const credentials = await Credentials.active(c.env);
|
|
10
|
+
if (!credentials)
|
|
11
|
+
return c.error({
|
|
12
|
+
code: 'SIGNED_OUT',
|
|
13
|
+
message: 'Not signed in. Run `login`.',
|
|
14
|
+
});
|
|
15
|
+
await client.faucet.fundSync({ account: credentials.key.account });
|
|
16
|
+
return { account: credentials.key.account, funded: true };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signs in: shows a code to approve in any browser, waits for approval, and saves the tokens with
|
|
3
|
+
* the CLI's new access key and its authorization.
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import * as Os from 'node:os';
|
|
7
|
+
import { Cli } from 'incur';
|
|
8
|
+
import * as Credentials from '../Credentials.js';
|
|
9
|
+
import { Account, Secp256k1 } from 'viem/tempo';
|
|
10
|
+
import * as TempoAccount from '../TempoAccount.js';
|
|
11
|
+
export default Cli.command({
|
|
12
|
+
description: 'Sign in by approving a code in any browser',
|
|
13
|
+
env: Credentials.env,
|
|
14
|
+
async *run(c) {
|
|
15
|
+
// The approval page shows the key's limits beside the device.
|
|
16
|
+
const privateKey = Secp256k1.randomPrivateKey();
|
|
17
|
+
const authorized = await fetch(new URL('/api/device/authorize', c.env.TEMPO_AUTH_URL), {
|
|
18
|
+
body: JSON.stringify({
|
|
19
|
+
device: Os.hostname(),
|
|
20
|
+
keyAuthorization: TempoAccount.getKeyAuthorizationRequest(Account.fromSecp256k1(privateKey)),
|
|
21
|
+
}),
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
method: 'POST',
|
|
24
|
+
});
|
|
25
|
+
if (!authorized.ok)
|
|
26
|
+
return c.error({ code: 'AUTHORIZE', message: 'Could not start sign-in.' });
|
|
27
|
+
const code = (await authorized.json());
|
|
28
|
+
yield `Open ${code.verification_uri_complete}`;
|
|
29
|
+
yield `and confirm the code ${code.user_code}.`;
|
|
30
|
+
let interval = code.interval;
|
|
31
|
+
const deadline = Date.now() + code.expires_in * 1000;
|
|
32
|
+
while (Date.now() < deadline) {
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
34
|
+
const response = await fetch(new URL('/api/device/token', c.env.TEMPO_AUTH_URL), {
|
|
35
|
+
body: JSON.stringify({ device_code: code.device_code }),
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
method: 'POST',
|
|
38
|
+
});
|
|
39
|
+
const body = (await response.json());
|
|
40
|
+
if ('access_token' in body) {
|
|
41
|
+
const credentials = await Credentials.save(c.env, {
|
|
42
|
+
key: {
|
|
43
|
+
account: body.account,
|
|
44
|
+
keyAuthorization: body.key_authorization,
|
|
45
|
+
privateKey,
|
|
46
|
+
},
|
|
47
|
+
tokens: body,
|
|
48
|
+
});
|
|
49
|
+
const email = await Credentials.email(c.env, { credentials });
|
|
50
|
+
// A streaming command prints what it yields, not what it returns.
|
|
51
|
+
yield `Signed in as ${email}.`;
|
|
52
|
+
return c.ok({ email });
|
|
53
|
+
}
|
|
54
|
+
// RFC 8628 asks a client told to slow down to add 5 seconds to its interval.
|
|
55
|
+
if (body.error === 'slow_down')
|
|
56
|
+
interval += 5;
|
|
57
|
+
else if (body.error !== 'authorization_pending')
|
|
58
|
+
return c.error({
|
|
59
|
+
code: 'DENIED',
|
|
60
|
+
message: `Sign-in failed: ${body.error}.`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return c.error({
|
|
64
|
+
code: 'EXPIRED',
|
|
65
|
+
message: 'The code expired. Sign in again.',
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Forgets the saved tokens. @module */
|
|
2
|
+
import { Cli } from 'incur';
|
|
3
|
+
import * as Credentials from '../Credentials.js';
|
|
4
|
+
export default Cli.command({
|
|
5
|
+
description: 'Sign out on this machine',
|
|
6
|
+
env: Credentials.env,
|
|
7
|
+
async run(c) {
|
|
8
|
+
await Credentials.remove(c.env);
|
|
9
|
+
return { signedOut: true };
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Sends pathUSD from the person's Tempo Account with the CLI's access key. @module */
|
|
2
|
+
import { Cli, z } from 'incur';
|
|
3
|
+
import { Value } from 'ox';
|
|
4
|
+
import { Addresses } from 'viem/tempo';
|
|
5
|
+
import * as Credentials from '../Credentials.js';
|
|
6
|
+
import * as TempoAccount from '../TempoAccount.js';
|
|
7
|
+
import { client } from '../viem.config.js';
|
|
8
|
+
export default Cli.command({
|
|
9
|
+
args: z.object({
|
|
10
|
+
to: z
|
|
11
|
+
.string()
|
|
12
|
+
.regex(/^0x[0-9a-fA-F]{40}$/)
|
|
13
|
+
.describe('Recipient address'),
|
|
14
|
+
amount: z
|
|
15
|
+
.string()
|
|
16
|
+
.regex(/^\d+(\.\d{1,6})?$/)
|
|
17
|
+
.describe('Amount of pathUSD, such as 0.01'),
|
|
18
|
+
}),
|
|
19
|
+
description: 'Send pathUSD from your Tempo Account',
|
|
20
|
+
env: Credentials.env,
|
|
21
|
+
async run(c) {
|
|
22
|
+
const credentials = await Credentials.active(c.env);
|
|
23
|
+
if (!credentials)
|
|
24
|
+
return c.error({
|
|
25
|
+
code: 'SIGNED_OUT',
|
|
26
|
+
message: 'Not signed in. Run `login`.',
|
|
27
|
+
});
|
|
28
|
+
const { receipt } = await client.token.transferSync({
|
|
29
|
+
account: TempoAccount.from(credentials.key),
|
|
30
|
+
amount: Value.from(c.args.amount, 6),
|
|
31
|
+
to: c.args.to,
|
|
32
|
+
token: Addresses.pathUsd,
|
|
33
|
+
});
|
|
34
|
+
return { status: receipt.status, transactionHash: receipt.transactionHash };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Prints the signed-in person's email and Tempo Account, refreshing the saved tokens when needed. @module */
|
|
2
|
+
import { Cli } from 'incur';
|
|
3
|
+
import * as Credentials from '../Credentials.js';
|
|
4
|
+
export default Cli.command({
|
|
5
|
+
description: 'Show who is signed in',
|
|
6
|
+
env: Credentials.env,
|
|
7
|
+
async run(c) {
|
|
8
|
+
const credentials = await Credentials.active(c.env);
|
|
9
|
+
if (!credentials)
|
|
10
|
+
return c.error({
|
|
11
|
+
code: 'SIGNED_OUT',
|
|
12
|
+
message: 'Not signed in. Run `login`.',
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
account: credentials.key.account,
|
|
16
|
+
email: await Credentials.email(c.env, { credentials }),
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* An internal CLI that signs in through Tempo Auth's device grant. Each command is a file in
|
|
4
|
+
* `commands/`, which incur loads by name.
|
|
5
|
+
*
|
|
6
|
+
* ```sh
|
|
7
|
+
* node index.ts login
|
|
8
|
+
* node index.ts whoami
|
|
9
|
+
* node index.ts fund
|
|
10
|
+
* node index.ts send 0x000000000000000000000000000000000000dEaD 0.01
|
|
11
|
+
* node index.ts logout
|
|
12
|
+
* ```
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { Cli } from 'incur';
|
|
16
|
+
await Cli.create('tempo', {
|
|
17
|
+
description: "Signs in to Tempo through Tempo Auth's device grant.",
|
|
18
|
+
})
|
|
19
|
+
.fs(new URL('./commands/', import.meta.url))
|
|
20
|
+
.serve();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The viem client the example shares: Tempo testnet, paying fees in pathUSD. @module */
|
|
2
|
+
import { Addresses, createClient } from 'viem/tempo';
|
|
3
|
+
import { tempoTestnet } from 'viem/tempo/chains';
|
|
4
|
+
export const client = createClient({
|
|
5
|
+
chain: tempoTestnet,
|
|
6
|
+
feeToken: Addresses.pathUsd,
|
|
7
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tempoxyz/example-auth-cli",
|
|
3
|
+
"version": "0.0.0-pr-5.sha-cd892ce",
|
|
4
|
+
"description": "Signs in to Tempo through Tempo Auth's device grant.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tempo": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"incur": "0.5.1",
|
|
17
|
+
"ox": "0.14.56",
|
|
18
|
+
"viem": "https://pkg.pr.new/viem@0872b53"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "26.6.4",
|
|
22
|
+
"typescript": "7.0.2"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"check:types": "tsc",
|
|
27
|
+
"start": "node index.ts"
|
|
28
|
+
}
|
|
29
|
+
}
|