t3code-cli 0.4.0 → 0.5.1
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/bin.js +4 -2
- package/dist/index.js +12 -2
- package/dist/{runtime-Cq64iuZr.js → runtime-KQVRmRk-.js} +229 -174
- package/dist/src/application/layer.d.ts +2 -2
- package/dist/src/auth/layer.d.ts +4 -2
- package/dist/src/auth/local-base-dir.d.ts +7 -0
- package/dist/src/auth/local-origin.d.ts +18 -0
- package/dist/src/auth/local-token.d.ts +26 -0
- package/dist/src/auth/local.d.ts +8 -11
- package/dist/src/auth/pairing.d.ts +6 -6
- package/dist/src/auth/service.d.ts +2 -1
- package/dist/src/auth/transport.d.ts +6 -3
- package/dist/src/auth/type.d.ts +18 -0
- package/dist/src/connection/error.d.ts +8 -0
- package/dist/src/connection/layer.d.ts +3 -0
- package/dist/src/connection/node.d.ts +2 -0
- package/dist/src/connection/service.d.ts +13 -0
- package/dist/src/connection/type.d.ts +10 -0
- package/dist/src/connection.d.ts +5 -0
- package/dist/src/index.d.ts +23 -1
- package/dist/src/rpc/error.d.ts +4 -3
- package/dist/src/rpc/layer.d.ts +5 -4
- package/dist/src/runtime.d.ts +9 -2
- package/package.json +1 -1
- package/src/auth/layer.ts +17 -0
- package/src/auth/local-base-dir.ts +22 -0
- package/src/auth/local-origin.ts +62 -0
- package/src/auth/local-token.ts +302 -0
- package/src/auth/local.ts +13 -348
- package/src/auth/pairing.ts +8 -19
- package/src/auth/service.ts +2 -1
- package/src/auth/transport.ts +17 -11
- package/src/auth/type.ts +22 -0
- package/src/cli/auth.ts +2 -0
- package/src/connection/error.ts +9 -0
- package/src/connection/layer.ts +10 -0
- package/src/connection/node.ts +12 -0
- package/src/connection/service.ts +26 -0
- package/src/connection/type.ts +12 -0
- package/src/connection.ts +9 -0
- package/src/index.ts +75 -1
- package/src/rpc/error.ts +4 -11
- package/src/rpc/layer.ts +25 -15
- package/src/runtime.ts +45 -7
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthAdministrativeScopes,
|
|
3
|
+
AuthSessionId,
|
|
4
|
+
type AuthEnvironmentScope,
|
|
5
|
+
} from "#t3tools/contracts";
|
|
6
|
+
import * as Context from "effect/Context";
|
|
7
|
+
import * as Crypto from "effect/Crypto";
|
|
8
|
+
import * as DateTime from "effect/DateTime";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Encoding from "effect/Encoding";
|
|
11
|
+
import * as Filter from "effect/Filter";
|
|
12
|
+
import * as FileSystem from "effect/FileSystem";
|
|
13
|
+
import * as Layer from "effect/Layer";
|
|
14
|
+
import * as Path from "effect/Path";
|
|
15
|
+
import * as Predicate from "effect/Predicate";
|
|
16
|
+
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
17
|
+
|
|
18
|
+
import { Environment } from "../environment/service.ts";
|
|
19
|
+
import { SqlClientFactory } from "../sql/service.ts";
|
|
20
|
+
import {
|
|
21
|
+
AuthLocalDatabaseError,
|
|
22
|
+
AuthLocalError,
|
|
23
|
+
AuthLocalSecretError,
|
|
24
|
+
AuthLocalSigningError,
|
|
25
|
+
} from "./error.ts";
|
|
26
|
+
import { resolveLocalBaseDir } from "./local-base-dir.ts";
|
|
27
|
+
import type { LocalAuthTokenInput, LocalAuthTokenResult } from "./type.ts";
|
|
28
|
+
|
|
29
|
+
export class T3LocalAuthToken extends Context.Service<
|
|
30
|
+
T3LocalAuthToken,
|
|
31
|
+
{
|
|
32
|
+
readonly create: (
|
|
33
|
+
input: LocalAuthTokenInput,
|
|
34
|
+
) => Effect.Effect<LocalAuthTokenResult, AuthLocalError>;
|
|
35
|
+
}
|
|
36
|
+
>()("t3cli/T3LocalAuthToken") {}
|
|
37
|
+
|
|
38
|
+
export const makeT3LocalAuthToken = Effect.fn("makeT3LocalAuthToken")(function* () {
|
|
39
|
+
const fs = yield* FileSystem.FileSystem;
|
|
40
|
+
const path = yield* Path.Path;
|
|
41
|
+
const environment = yield* Environment;
|
|
42
|
+
const crypto = yield* Crypto.Crypto;
|
|
43
|
+
const sqlClientFactory = yield* SqlClientFactory;
|
|
44
|
+
|
|
45
|
+
function readSigningSecret(secretPath: string) {
|
|
46
|
+
return fs.readFile(secretPath).pipe(
|
|
47
|
+
Effect.map((bytes) => Uint8Array.from(bytes)),
|
|
48
|
+
Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () =>
|
|
49
|
+
Effect.succeed(undefined),
|
|
50
|
+
),
|
|
51
|
+
Effect.mapError(
|
|
52
|
+
(error) =>
|
|
53
|
+
new AuthLocalSecretError({
|
|
54
|
+
message: `failed to read signing secret: ${secretPath}`,
|
|
55
|
+
cause: error,
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const readRequiredSigningSecret = Effect.fn("readRequiredSigningSecret")(function* (
|
|
62
|
+
secretsDir: string,
|
|
63
|
+
) {
|
|
64
|
+
const secretPath = path.join(secretsDir, `${signingSecretName}.bin`);
|
|
65
|
+
const secret = yield* readSigningSecret(secretPath);
|
|
66
|
+
if (secret === undefined) {
|
|
67
|
+
return yield* Effect.fail(
|
|
68
|
+
new AuthLocalSecretError({
|
|
69
|
+
message: `local signing secret not found: ${secretPath}`,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return secret;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const hmacSha256 = (secret: Uint8Array, payload: Uint8Array) =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const key =
|
|
79
|
+
secret.byteLength > sha256BlockSize ? yield* crypto.digest("SHA-256", secret) : secret;
|
|
80
|
+
const block = new Uint8Array(sha256BlockSize);
|
|
81
|
+
block.set(key);
|
|
82
|
+
const outerPad = block.map((byte) => byte ^ 0x5c);
|
|
83
|
+
const innerPad = block.map((byte) => byte ^ 0x36);
|
|
84
|
+
const innerHash = yield* crypto.digest("SHA-256", concatBytes(innerPad, payload));
|
|
85
|
+
return yield* crypto.digest("SHA-256", concatBytes(outerPad, innerHash));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const signPayload = (payload: string, secret: Uint8Array) =>
|
|
89
|
+
hmacSha256(secret, new TextEncoder().encode(payload)).pipe(
|
|
90
|
+
Effect.map(Encoding.encodeBase64Url),
|
|
91
|
+
Effect.mapError(
|
|
92
|
+
(error) =>
|
|
93
|
+
new AuthLocalSigningError({
|
|
94
|
+
operation: "sign",
|
|
95
|
+
message: "failed to sign local auth payload",
|
|
96
|
+
cause: error,
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
function openAuthDatabase(dbPath: string) {
|
|
102
|
+
return sqlClientFactory.sqliteClient({ filename: dbPath }).pipe(
|
|
103
|
+
Effect.catchTag("SqlError", (error) =>
|
|
104
|
+
Effect.fail(
|
|
105
|
+
new AuthLocalDatabaseError({
|
|
106
|
+
operation: "connect",
|
|
107
|
+
message: error.message,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const provideAuthDatabase =
|
|
115
|
+
(dbPath: string) =>
|
|
116
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const sql = yield* openAuthDatabase(dbPath);
|
|
119
|
+
return yield* effect.pipe(Effect.provideService(SqlClient.SqlClient, sql));
|
|
120
|
+
}).pipe(Effect.scoped);
|
|
121
|
+
|
|
122
|
+
const issueLocalDatabaseSession = Effect.fn("issueLocalDatabaseSession")(function* (
|
|
123
|
+
input: LocalDatabaseSessionInput,
|
|
124
|
+
) {
|
|
125
|
+
const secret = yield* readRequiredSigningSecret(input.secretsDir);
|
|
126
|
+
const issuedAt = yield* DateTime.now;
|
|
127
|
+
const expiresAt = DateTime.add(issuedAt, { milliseconds: defaultSessionTtlMs });
|
|
128
|
+
const sessionId = yield* crypto.randomUUIDv4.pipe(
|
|
129
|
+
Effect.map((id) => AuthSessionId.make(id)),
|
|
130
|
+
Effect.mapError(
|
|
131
|
+
(error) =>
|
|
132
|
+
new AuthLocalSecretError({
|
|
133
|
+
message: "failed to generate auth session id",
|
|
134
|
+
cause: error,
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
const scopes = [...AuthAdministrativeScopes];
|
|
139
|
+
const claims: LocalSessionClaims = {
|
|
140
|
+
v: 1,
|
|
141
|
+
kind: "session",
|
|
142
|
+
sid: sessionId,
|
|
143
|
+
sub: input.subject,
|
|
144
|
+
scopes,
|
|
145
|
+
method: "bearer-access-token",
|
|
146
|
+
iat: DateTime.toEpochMillis(issuedAt),
|
|
147
|
+
exp: DateTime.toEpochMillis(expiresAt),
|
|
148
|
+
};
|
|
149
|
+
const encodedPayload = Encoding.encodeBase64Url(JSON.stringify(claims));
|
|
150
|
+
const token = `${encodedPayload}.${yield* signPayload(encodedPayload, secret)}`;
|
|
151
|
+
yield* insertAuthSession({
|
|
152
|
+
sessionId,
|
|
153
|
+
subject: input.subject,
|
|
154
|
+
scopes,
|
|
155
|
+
label: input.label,
|
|
156
|
+
issuedAt: DateTime.formatIso(issuedAt),
|
|
157
|
+
expiresAt: DateTime.formatIso(expiresAt),
|
|
158
|
+
}).pipe(
|
|
159
|
+
provideAuthDatabase(input.dbPath),
|
|
160
|
+
Effect.catchTag("SqlError", (error) =>
|
|
161
|
+
Effect.fail(
|
|
162
|
+
new AuthLocalDatabaseError({
|
|
163
|
+
operation: Predicate.isTagged(error.reason, "ConnectionError") ? "connect" : "query",
|
|
164
|
+
message: error.message,
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
return {
|
|
170
|
+
token,
|
|
171
|
+
role: input.role,
|
|
172
|
+
expiresAt: DateTime.formatIso(expiresAt),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const create = Effect.fn("T3LocalAuthTokenLive.create")(function* (input: LocalAuthTokenInput) {
|
|
177
|
+
if (input.label.length === 0) {
|
|
178
|
+
return yield* Effect.fail(
|
|
179
|
+
new AuthLocalError({ message: "local auth label cannot be empty" }),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (input.subject.length === 0) {
|
|
183
|
+
return yield* Effect.fail(
|
|
184
|
+
new AuthLocalError({ message: "local auth subject cannot be empty" }),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const baseDir = resolveLocalBaseDir({ baseDir: input.baseDir, environment, path });
|
|
189
|
+
const session = yield* issueLocalDatabaseSession({
|
|
190
|
+
dbPath: path.join(baseDir, "userdata", "state.sqlite"),
|
|
191
|
+
secretsDir: path.join(baseDir, "userdata", "secrets"),
|
|
192
|
+
role: input.role,
|
|
193
|
+
label: input.label,
|
|
194
|
+
subject: input.subject,
|
|
195
|
+
}).pipe(
|
|
196
|
+
Effect.mapError(
|
|
197
|
+
(error) =>
|
|
198
|
+
new AuthLocalError({ message: `local auth failed: ${error.message}`, cause: error }),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
token: session.token,
|
|
203
|
+
role: session.role,
|
|
204
|
+
expiresAt: session.expiresAt,
|
|
205
|
+
source: "local" as const,
|
|
206
|
+
baseDir,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { create };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
export const T3LocalAuthTokenLive = Layer.effect(T3LocalAuthToken, makeT3LocalAuthToken());
|
|
214
|
+
|
|
215
|
+
function insertAuthSession(input: InsertAuthSessionInput) {
|
|
216
|
+
return Effect.gen(function* () {
|
|
217
|
+
const sql = yield* SqlClient.SqlClient;
|
|
218
|
+
yield* sql`PRAGMA busy_timeout = 5000;`;
|
|
219
|
+
yield* sql`PRAGMA foreign_keys = ON;`;
|
|
220
|
+
const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(auth_sessions)`;
|
|
221
|
+
if (!columns.some((column) => column.name === "scopes")) {
|
|
222
|
+
return yield* Effect.fail(
|
|
223
|
+
new AuthLocalDatabaseError({
|
|
224
|
+
operation: "schema",
|
|
225
|
+
message: "local auth database is missing scoped auth_sessions schema",
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
yield* sql`
|
|
230
|
+
INSERT INTO auth_sessions (
|
|
231
|
+
session_id,
|
|
232
|
+
subject,
|
|
233
|
+
scopes,
|
|
234
|
+
method,
|
|
235
|
+
client_label,
|
|
236
|
+
client_ip_address,
|
|
237
|
+
client_user_agent,
|
|
238
|
+
client_device_type,
|
|
239
|
+
client_os,
|
|
240
|
+
client_browser,
|
|
241
|
+
issued_at,
|
|
242
|
+
expires_at,
|
|
243
|
+
revoked_at
|
|
244
|
+
)
|
|
245
|
+
VALUES (
|
|
246
|
+
${input.sessionId},
|
|
247
|
+
${input.subject},
|
|
248
|
+
${JSON.stringify(input.scopes)},
|
|
249
|
+
${"bearer-access-token"},
|
|
250
|
+
${input.label},
|
|
251
|
+
NULL,
|
|
252
|
+
NULL,
|
|
253
|
+
${"bot"},
|
|
254
|
+
NULL,
|
|
255
|
+
NULL,
|
|
256
|
+
${input.issuedAt},
|
|
257
|
+
${input.expiresAt},
|
|
258
|
+
NULL
|
|
259
|
+
)
|
|
260
|
+
`;
|
|
261
|
+
return undefined;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
type LocalSessionClaims = {
|
|
266
|
+
readonly v: 1;
|
|
267
|
+
readonly kind: "session";
|
|
268
|
+
readonly sid: AuthSessionId;
|
|
269
|
+
readonly sub: string;
|
|
270
|
+
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
|
|
271
|
+
readonly method: "bearer-access-token";
|
|
272
|
+
readonly iat: number;
|
|
273
|
+
readonly exp: number;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
type LocalDatabaseSessionInput = {
|
|
277
|
+
readonly dbPath: string;
|
|
278
|
+
readonly secretsDir: string;
|
|
279
|
+
readonly role: LocalAuthTokenInput["role"];
|
|
280
|
+
readonly label: string;
|
|
281
|
+
readonly subject: string;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
type InsertAuthSessionInput = {
|
|
285
|
+
readonly sessionId: AuthSessionId;
|
|
286
|
+
readonly subject: string;
|
|
287
|
+
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
|
|
288
|
+
readonly label: string;
|
|
289
|
+
readonly issuedAt: string;
|
|
290
|
+
readonly expiresAt: string;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const defaultSessionTtlMs = 30 * 24 * 60 * 60 * 1000;
|
|
294
|
+
const signingSecretName = "server-signing-key";
|
|
295
|
+
const sha256BlockSize = 64;
|
|
296
|
+
|
|
297
|
+
function concatBytes(first: Uint8Array, second: Uint8Array) {
|
|
298
|
+
const bytes = new Uint8Array(first.byteLength + second.byteLength);
|
|
299
|
+
bytes.set(first);
|
|
300
|
+
bytes.set(second, first.byteLength);
|
|
301
|
+
return bytes;
|
|
302
|
+
}
|
package/src/auth/local.ts
CHANGED
|
@@ -1,276 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AuthAdministrativeScopes,
|
|
3
|
-
AuthSessionId,
|
|
4
|
-
type AuthEnvironmentScope,
|
|
5
|
-
} from "#t3tools/contracts";
|
|
6
1
|
import * as Context from "effect/Context";
|
|
7
|
-
import * as Crypto from "effect/Crypto";
|
|
8
|
-
import * as DateTime from "effect/DateTime";
|
|
9
2
|
import * as Effect from "effect/Effect";
|
|
10
|
-
import * as Encoding from "effect/Encoding";
|
|
11
|
-
import * as Filter from "effect/Filter";
|
|
12
|
-
import * as FileSystem from "effect/FileSystem";
|
|
13
3
|
import * as Layer from "effect/Layer";
|
|
14
|
-
import * as Path from "effect/Path";
|
|
15
|
-
import * as Predicate from "effect/Predicate";
|
|
16
|
-
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
17
4
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { SqlClientFactory } from "../sql/service.ts";
|
|
22
|
-
import {
|
|
23
|
-
AuthConfigError,
|
|
24
|
-
AuthLocalDatabaseError,
|
|
25
|
-
AuthLocalError,
|
|
26
|
-
AuthLocalSecretError,
|
|
27
|
-
AuthLocalSigningError,
|
|
28
|
-
} from "./error.ts";
|
|
29
|
-
import { decodeAuthLocalRuntimeStateFromJson } from "./schema.ts";
|
|
5
|
+
import { AuthLocalError } from "./error.ts";
|
|
6
|
+
import { T3LocalAuthOrigin } from "./local-origin.ts";
|
|
7
|
+
import { T3LocalAuthToken } from "./local-token.ts";
|
|
30
8
|
import type { LocalAuthInput, LocalAuthResult } from "./type.ts";
|
|
31
9
|
|
|
32
10
|
export class T3LocalAuth extends Context.Service<
|
|
33
11
|
T3LocalAuth,
|
|
34
12
|
{
|
|
35
|
-
readonly local: (
|
|
36
|
-
input: LocalAuthInput,
|
|
37
|
-
) => Effect.Effect<LocalAuthResult, AuthConfigError | AuthLocalError>;
|
|
13
|
+
readonly local: (input: LocalAuthInput) => Effect.Effect<LocalAuthResult, AuthLocalError>;
|
|
38
14
|
}
|
|
39
15
|
>()("t3cli/T3LocalAuth") {}
|
|
40
16
|
|
|
41
17
|
export const makeT3LocalAuth = Effect.fn("makeT3LocalAuth")(function* () {
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const path = yield* Path.Path;
|
|
45
|
-
const environment = yield* Environment;
|
|
46
|
-
const crypto = yield* Crypto.Crypto;
|
|
47
|
-
const sqlClientFactory = yield* SqlClientFactory;
|
|
48
|
-
|
|
49
|
-
function resolveLocalBaseDir(input: string | undefined) {
|
|
50
|
-
const envBaseDir = environment.env["T3CODE_HOME"];
|
|
51
|
-
const raw = input ?? envBaseDir;
|
|
52
|
-
if (raw === undefined || raw.length === 0) {
|
|
53
|
-
return path.join(environment.homeDir, ".t3");
|
|
54
|
-
}
|
|
55
|
-
if (raw === "~") {
|
|
56
|
-
return environment.homeDir;
|
|
57
|
-
}
|
|
58
|
-
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
59
|
-
return path.join(environment.homeDir, raw.slice(2));
|
|
60
|
-
}
|
|
61
|
-
return path.resolve(environment.cwd, raw);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function resolveLocalOrigin(input: { readonly baseDir: string; readonly origin?: string }) {
|
|
65
|
-
return Effect.gen(function* () {
|
|
66
|
-
if (input.origin !== undefined) {
|
|
67
|
-
return yield* normalizeLocalOrigin(input.origin);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const runtimeStatePath = path.join(input.baseDir, "userdata", "server-runtime.json");
|
|
71
|
-
const raw = yield* fs.readFileString(runtimeStatePath).pipe(
|
|
72
|
-
Effect.mapError(
|
|
73
|
-
(error) =>
|
|
74
|
-
new AuthLocalError({
|
|
75
|
-
message: `local runtime state not found: ${runtimeStatePath}. Make sure T3 Code is running with Network access enabled, or pass --origin manually.`,
|
|
76
|
-
cause: error,
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
79
|
-
);
|
|
80
|
-
const state = yield* decodeAuthLocalRuntimeStateFromJson(raw).pipe(
|
|
81
|
-
Effect.mapError(
|
|
82
|
-
(error) =>
|
|
83
|
-
new AuthLocalError({ message: "local runtime state has invalid shape", cause: error }),
|
|
84
|
-
),
|
|
85
|
-
);
|
|
86
|
-
return yield* normalizeLocalOrigin(state.origin);
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function readSigningSecret(secretPath: string) {
|
|
91
|
-
return fs.readFile(secretPath).pipe(
|
|
92
|
-
Effect.map((bytes) => Uint8Array.from(bytes)),
|
|
93
|
-
Effect.catchFilter(Filter.reason("PlatformError", "NotFound"), () =>
|
|
94
|
-
Effect.succeed(undefined),
|
|
95
|
-
),
|
|
96
|
-
Effect.mapError(
|
|
97
|
-
(error) =>
|
|
98
|
-
new AuthLocalSecretError({
|
|
99
|
-
message: `failed to read signing secret: ${secretPath}`,
|
|
100
|
-
cause: error,
|
|
101
|
-
}),
|
|
102
|
-
),
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const readRequiredSigningSecret = Effect.fn("readRequiredSigningSecret")(function* (
|
|
107
|
-
secretsDir: string,
|
|
108
|
-
) {
|
|
109
|
-
const secretPath = path.join(secretsDir, `${signingSecretName}.bin`);
|
|
110
|
-
const secret = yield* readSigningSecret(secretPath);
|
|
111
|
-
if (secret === undefined) {
|
|
112
|
-
return yield* Effect.fail(
|
|
113
|
-
new AuthLocalSecretError({
|
|
114
|
-
message: `local signing secret not found: ${secretPath}`,
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
return secret;
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const hmacSha256 = (secret: Uint8Array, payload: Uint8Array) =>
|
|
122
|
-
Effect.gen(function* () {
|
|
123
|
-
const key =
|
|
124
|
-
secret.byteLength > sha256BlockSize ? yield* crypto.digest("SHA-256", secret) : secret;
|
|
125
|
-
const block = new Uint8Array(sha256BlockSize);
|
|
126
|
-
block.set(key);
|
|
127
|
-
const outerPad = block.map((byte) => byte ^ 0x5c);
|
|
128
|
-
const innerPad = block.map((byte) => byte ^ 0x36);
|
|
129
|
-
const innerHash = yield* crypto.digest("SHA-256", concatBytes(innerPad, payload));
|
|
130
|
-
return yield* crypto.digest("SHA-256", concatBytes(outerPad, innerHash));
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const signPayload = (payload: string, secret: Uint8Array) =>
|
|
134
|
-
hmacSha256(secret, new TextEncoder().encode(payload)).pipe(
|
|
135
|
-
Effect.map(Encoding.encodeBase64Url),
|
|
136
|
-
Effect.mapError(
|
|
137
|
-
(error) =>
|
|
138
|
-
new AuthLocalSigningError({
|
|
139
|
-
operation: "sign",
|
|
140
|
-
message: "failed to sign local auth payload",
|
|
141
|
-
cause: error,
|
|
142
|
-
}),
|
|
143
|
-
),
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
function openAuthDatabase(dbPath: string) {
|
|
147
|
-
return sqlClientFactory.sqliteClient({ filename: dbPath }).pipe(
|
|
148
|
-
Effect.catchTag("SqlError", (error) =>
|
|
149
|
-
Effect.fail(
|
|
150
|
-
new AuthLocalDatabaseError({
|
|
151
|
-
operation: "connect",
|
|
152
|
-
message: error.message,
|
|
153
|
-
}),
|
|
154
|
-
),
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const provideAuthDatabase =
|
|
160
|
-
(dbPath: string) =>
|
|
161
|
-
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
162
|
-
Effect.gen(function* () {
|
|
163
|
-
const sql = yield* openAuthDatabase(dbPath);
|
|
164
|
-
return yield* effect.pipe(Effect.provideService(SqlClient.SqlClient, sql));
|
|
165
|
-
}).pipe(Effect.scoped);
|
|
166
|
-
|
|
167
|
-
const issueLocalDatabaseSession = Effect.fn("issueLocalDatabaseSession")(function* (
|
|
168
|
-
input: LocalDatabaseSessionInput,
|
|
169
|
-
) {
|
|
170
|
-
const secret = yield* readRequiredSigningSecret(input.secretsDir);
|
|
171
|
-
const issuedAt = yield* DateTime.now;
|
|
172
|
-
const expiresAt = DateTime.add(issuedAt, { milliseconds: defaultSessionTtlMs });
|
|
173
|
-
const sessionId = yield* crypto.randomUUIDv4.pipe(
|
|
174
|
-
Effect.map((id) => AuthSessionId.make(id)),
|
|
175
|
-
Effect.mapError(
|
|
176
|
-
(error) =>
|
|
177
|
-
new AuthLocalSecretError({
|
|
178
|
-
message: "failed to generate auth session id",
|
|
179
|
-
cause: error,
|
|
180
|
-
}),
|
|
181
|
-
),
|
|
182
|
-
);
|
|
183
|
-
const scopes = [...AuthAdministrativeScopes];
|
|
184
|
-
const claims: LocalSessionClaims = {
|
|
185
|
-
v: 1,
|
|
186
|
-
kind: "session",
|
|
187
|
-
sid: sessionId,
|
|
188
|
-
sub: input.subject,
|
|
189
|
-
scopes,
|
|
190
|
-
method: "bearer-access-token",
|
|
191
|
-
iat: DateTime.toEpochMillis(issuedAt),
|
|
192
|
-
exp: DateTime.toEpochMillis(expiresAt),
|
|
193
|
-
};
|
|
194
|
-
const encodedPayload = Encoding.encodeBase64Url(JSON.stringify(claims));
|
|
195
|
-
const token = `${encodedPayload}.${yield* signPayload(encodedPayload, secret)}`;
|
|
196
|
-
yield* insertAuthSession({
|
|
197
|
-
sessionId,
|
|
198
|
-
subject: input.subject,
|
|
199
|
-
scopes,
|
|
200
|
-
label: input.label,
|
|
201
|
-
issuedAt: DateTime.formatIso(issuedAt),
|
|
202
|
-
expiresAt: DateTime.formatIso(expiresAt),
|
|
203
|
-
}).pipe(
|
|
204
|
-
provideAuthDatabase(input.dbPath),
|
|
205
|
-
Effect.catchTag("SqlError", (error) =>
|
|
206
|
-
Effect.fail(
|
|
207
|
-
new AuthLocalDatabaseError({
|
|
208
|
-
operation: Predicate.isTagged(error.reason, "ConnectionError") ? "connect" : "query",
|
|
209
|
-
message: error.message,
|
|
210
|
-
}),
|
|
211
|
-
),
|
|
212
|
-
),
|
|
213
|
-
);
|
|
214
|
-
return {
|
|
215
|
-
token,
|
|
216
|
-
role: input.role,
|
|
217
|
-
expiresAt: DateTime.formatIso(expiresAt),
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
function writeLocalConfig(input: { readonly url: string; readonly token: string }) {
|
|
222
|
-
return Effect.gen(function* () {
|
|
223
|
-
const existing = yield* config.readStored().pipe(
|
|
224
|
-
Effect.catchTags({
|
|
225
|
-
ConfigError: (error) =>
|
|
226
|
-
Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })),
|
|
227
|
-
}),
|
|
228
|
-
);
|
|
229
|
-
yield* config.writeStored({ ...existing, url: input.url, token: input.token }).pipe(
|
|
230
|
-
Effect.catchTags({
|
|
231
|
-
ConfigError: (error) =>
|
|
232
|
-
Effect.fail(new AuthConfigError({ message: "auth config failed", cause: error })),
|
|
233
|
-
}),
|
|
234
|
-
);
|
|
235
|
-
});
|
|
236
|
-
}
|
|
18
|
+
const origin = yield* T3LocalAuthOrigin;
|
|
19
|
+
const token = yield* T3LocalAuthToken;
|
|
237
20
|
|
|
238
21
|
const local = Effect.fn("T3LocalAuthLive.local")(function* (input: LocalAuthInput) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
if (input.subject.length === 0) {
|
|
245
|
-
return yield* Effect.fail(
|
|
246
|
-
new AuthLocalError({ message: "local auth subject cannot be empty" }),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const baseDir = resolveLocalBaseDir(input.baseDir);
|
|
251
|
-
const session = yield* issueLocalDatabaseSession({
|
|
252
|
-
dbPath: path.join(baseDir, "userdata", "state.sqlite"),
|
|
253
|
-
secretsDir: path.join(baseDir, "userdata", "secrets"),
|
|
254
|
-
role: input.role,
|
|
255
|
-
label: input.label,
|
|
256
|
-
subject: input.subject,
|
|
257
|
-
}).pipe(
|
|
258
|
-
Effect.mapError(
|
|
259
|
-
(error) =>
|
|
260
|
-
new AuthLocalError({ message: `local auth failed: ${error.message}`, cause: error }),
|
|
261
|
-
),
|
|
262
|
-
);
|
|
263
|
-
const url = yield* resolveLocalOrigin({
|
|
264
|
-
baseDir,
|
|
22
|
+
const created = yield* token.create(input);
|
|
23
|
+
const url = yield* origin.resolve({
|
|
24
|
+
baseDir: created.baseDir,
|
|
265
25
|
...(input.origin !== undefined ? { origin: input.origin } : {}),
|
|
266
26
|
});
|
|
267
|
-
yield* writeLocalConfig({ url, token: session.token });
|
|
268
27
|
return {
|
|
269
28
|
url,
|
|
270
|
-
|
|
271
|
-
|
|
29
|
+
token: created.token,
|
|
30
|
+
role: created.role,
|
|
31
|
+
expiresAt: created.expiresAt,
|
|
272
32
|
source: "local" as const,
|
|
273
|
-
baseDir,
|
|
33
|
+
baseDir: created.baseDir,
|
|
274
34
|
};
|
|
275
35
|
});
|
|
276
36
|
|
|
@@ -278,98 +38,3 @@ export const makeT3LocalAuth = Effect.fn("makeT3LocalAuth")(function* () {
|
|
|
278
38
|
});
|
|
279
39
|
|
|
280
40
|
export const T3LocalAuthLive = Layer.effect(T3LocalAuth, makeT3LocalAuth());
|
|
281
|
-
|
|
282
|
-
function insertAuthSession(input: InsertAuthSessionInput) {
|
|
283
|
-
return Effect.gen(function* () {
|
|
284
|
-
const sql = yield* SqlClient.SqlClient;
|
|
285
|
-
yield* sql`PRAGMA busy_timeout = 5000;`;
|
|
286
|
-
yield* sql`PRAGMA foreign_keys = ON;`;
|
|
287
|
-
const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(auth_sessions)`;
|
|
288
|
-
if (!columns.some((column) => column.name === "scopes")) {
|
|
289
|
-
return yield* Effect.fail(
|
|
290
|
-
new AuthLocalDatabaseError({
|
|
291
|
-
operation: "schema",
|
|
292
|
-
message: "local auth database is missing scoped auth_sessions schema",
|
|
293
|
-
}),
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
yield* sql`
|
|
297
|
-
INSERT INTO auth_sessions (
|
|
298
|
-
session_id,
|
|
299
|
-
subject,
|
|
300
|
-
scopes,
|
|
301
|
-
method,
|
|
302
|
-
client_label,
|
|
303
|
-
client_ip_address,
|
|
304
|
-
client_user_agent,
|
|
305
|
-
client_device_type,
|
|
306
|
-
client_os,
|
|
307
|
-
client_browser,
|
|
308
|
-
issued_at,
|
|
309
|
-
expires_at,
|
|
310
|
-
revoked_at
|
|
311
|
-
)
|
|
312
|
-
VALUES (
|
|
313
|
-
${input.sessionId},
|
|
314
|
-
${input.subject},
|
|
315
|
-
${JSON.stringify(input.scopes)},
|
|
316
|
-
${"bearer-access-token"},
|
|
317
|
-
${input.label},
|
|
318
|
-
NULL,
|
|
319
|
-
NULL,
|
|
320
|
-
${"bot"},
|
|
321
|
-
NULL,
|
|
322
|
-
NULL,
|
|
323
|
-
${input.issuedAt},
|
|
324
|
-
${input.expiresAt},
|
|
325
|
-
NULL
|
|
326
|
-
)
|
|
327
|
-
`;
|
|
328
|
-
return undefined;
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function normalizeLocalOrigin(origin: string) {
|
|
333
|
-
return normalizeHttpBaseUrl(origin).pipe(
|
|
334
|
-
Effect.mapError((error) => new AuthLocalError({ message: error.message, cause: error })),
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
type LocalSessionClaims = {
|
|
339
|
-
readonly v: 1;
|
|
340
|
-
readonly kind: "session";
|
|
341
|
-
readonly sid: AuthSessionId;
|
|
342
|
-
readonly sub: string;
|
|
343
|
-
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
|
|
344
|
-
readonly method: "bearer-access-token";
|
|
345
|
-
readonly iat: number;
|
|
346
|
-
readonly exp: number;
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
type LocalDatabaseSessionInput = {
|
|
350
|
-
readonly dbPath: string;
|
|
351
|
-
readonly secretsDir: string;
|
|
352
|
-
readonly role: LocalAuthInput["role"];
|
|
353
|
-
readonly label: string;
|
|
354
|
-
readonly subject: string;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
type InsertAuthSessionInput = {
|
|
358
|
-
readonly sessionId: AuthSessionId;
|
|
359
|
-
readonly subject: string;
|
|
360
|
-
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
|
|
361
|
-
readonly label: string;
|
|
362
|
-
readonly issuedAt: string;
|
|
363
|
-
readonly expiresAt: string;
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const defaultSessionTtlMs = 30 * 24 * 60 * 60 * 1000;
|
|
367
|
-
const signingSecretName = "server-signing-key";
|
|
368
|
-
const sha256BlockSize = 64;
|
|
369
|
-
|
|
370
|
-
function concatBytes(first: Uint8Array, second: Uint8Array) {
|
|
371
|
-
const bytes = new Uint8Array(first.byteLength + second.byteLength);
|
|
372
|
-
bytes.set(first);
|
|
373
|
-
bytes.set(second, first.byteLength);
|
|
374
|
-
return bytes;
|
|
375
|
-
}
|