1import * as Context from "effect/Context";
2import * as Digest from "effect/crypto/Digest";
3import * as SecureRandom from "effect/crypto/SecureRandom";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Base64Url from "effect/encoding/Base64Url";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm";
11import { QueryBuilder } from "drizzle-orm/pg-core";
12
13import * as RelayDb from "../db.ts";
14import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts";
15
16export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedError<EnvironmentCredentialCreatePersistenceError>()(
17 "EnvironmentCredentialCreatePersistenceError",
18 {
19 stage: Schema.Literals([
20 "generate-credential",
21 "hash-token",
22 "insert-credential",
23 "revoke-previous-credentials",
24 ]),
25 environmentId: Schema.String,
26 credentialId: Schema.optionalKey(Schema.String),
27 cause: Schema.Defect(),
28 },
29) {
30 override get message(): string {
31 return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`;
32 }
33}
34
35export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedError<EnvironmentCredentialAuthenticatePersistenceError>()(
36 "EnvironmentCredentialAuthenticatePersistenceError",
37 {
38 stage: Schema.Literals(["hash-token", "lookup-credential"]),
39 cause: Schema.Defect(),
40 },
41) {
42 override get message(): string {
43 return `Environment credential authentication failed during '${this.stage}'`;
44 }
45}
46
47export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedError<EnvironmentCredentialRevokePersistenceError>()(
48 "EnvironmentCredentialRevokePersistenceError",
49 {
50 environmentId: Schema.String,
51 cause: Schema.Defect(),
52 },
53) {
54 override get message(): string {
55 return `Failed to revoke credentials for environment '${this.environmentId}'`;
56 }
57}
58
59export interface EnvironmentCredentialPrincipal {
60 readonly credentialId: string;
61 readonly environmentId: string;
62 readonly environmentPublicKey: string;
63}
64
65export class EnvironmentCredentials extends Context.Service<
66 EnvironmentCredentials,
67 {
68 readonly create: (input: {
69 readonly environmentId: string;
70 readonly environmentPublicKey: string;
71 }) => Effect.Effect<string, EnvironmentCredentialCreatePersistenceError>;
72 readonly authenticate: (
73 token: string,
74 ) => Effect.Effect<
75 Option.Option<EnvironmentCredentialPrincipal>,
76 EnvironmentCredentialAuthenticatePersistenceError
77 >;
78 readonly revokeForEnvironmentPublicKey: (input: {
79 readonly environmentId: string;
80 readonly environmentPublicKey: string;
81 }) => Effect.Effect<boolean, EnvironmentCredentialRevokePersistenceError>;
82 }
83>()("t3code-relay/environments/EnvironmentCredentials") {}
84
85const make = Effect.gen(function* () {
86 const db = yield* RelayDb.RelayDb;
87 const random = yield* SecureRandom.SecureRandom;
88 const digest = yield* Digest.Digest;
89 const hashToken = (token: string) =>
90 digest
91 .digest(Digest.sha256, new TextEncoder().encode(token))
92 .pipe(Effect.map(Base64Url.encode));
93 const randomTokenPart = (segments: number) =>
94 Effect.map(Effect.all(Array.from({ length: segments }, () => random.uuidV4())), (values) =>
95 values.join("").replaceAll("-", ""),
96 );
97 const makeCredential = Effect.fnUntraced(function* () {
98 const credentialId = yield* randomTokenPart(2);
99 const secret = yield* randomTokenPart(3);
100 return {
101 credentialId,
102 token: `t3env_${credentialId}_${secret}`,
103 };
104 });
105
106 return EnvironmentCredentials.of({
107 create: Effect.fn("relay.environment_credentials.create")(function* (input) {
108 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
109 const credential = yield* makeCredential().pipe(
110 Effect.mapError(
111 (cause) =>
112 new EnvironmentCredentialCreatePersistenceError({
113 stage: "generate-credential",
114 environmentId: input.environmentId,
115 cause,
116 }),
117 ),
118 );
119 const credentialHash = yield* hashToken(credential.token).pipe(
120 Effect.mapError(
121 (cause) =>
122 new EnvironmentCredentialCreatePersistenceError({
123 stage: "hash-token",
124 environmentId: input.environmentId,
125 credentialId: credential.credentialId,
126 cause,
127 }),
128 ),
129 );
130 const now = DateTime.formatIso(yield* DateTime.now);
131 yield* db
132 .insert(relayEnvironmentCredentials)
133 .values({
134 credentialId: credential.credentialId,
135 environmentId: input.environmentId,
136 environmentPublicKey: input.environmentPublicKey,
137 credentialHash,
138 revokedAt: null,
139 createdAt: now,
140 updatedAt: now,
141 })
142 .pipe(
143 Effect.mapError(
144 (cause) =>
145 new EnvironmentCredentialCreatePersistenceError({
146 stage: "insert-credential",
147 environmentId: input.environmentId,
148 credentialId: credential.credentialId,
149 cause,
150 }),
151 ),
152 );
153 yield* db
154 .update(relayEnvironmentCredentials)
155 .set({
156 revokedAt: now,
157 updatedAt: now,
158 })
159 .where(
160 and(
161 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
162 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
163 ne(relayEnvironmentCredentials.credentialId, credential.credentialId),
164 isNull(relayEnvironmentCredentials.revokedAt),
165 ),
166 )
167 .pipe(
168 Effect.mapError(
169 (cause) =>
170 new EnvironmentCredentialCreatePersistenceError({
171 stage: "revoke-previous-credentials",
172 environmentId: input.environmentId,
173 credentialId: credential.credentialId,
174 cause,
175 }),
176 ),
177 );
178 return credential.token;
179 }),
180
181 authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) {
182 const credentialHash = yield* hashToken(token).pipe(
183 Effect.mapError(
184 (cause) =>
185 new EnvironmentCredentialAuthenticatePersistenceError({
186 stage: "hash-token",
187 cause,
188 }),
189 ),
190 );
191 const rows = yield* db
192 .select({
193 credentialId: relayEnvironmentCredentials.credentialId,
194 environmentId: relayEnvironmentCredentials.environmentId,
195 environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey,
196 })
197 .from(relayEnvironmentCredentials)
198 .where(
199 and(
200 eq(relayEnvironmentCredentials.credentialHash, credentialHash),
201 isNull(relayEnvironmentCredentials.revokedAt),
202 exists(
203 new QueryBuilder()
204 .select({ userId: relayEnvironmentLinks.userId })
205 .from(relayEnvironmentLinks)
206 .where(
207 and(
208 eq(
209 relayEnvironmentLinks.environmentId,
210 relayEnvironmentCredentials.environmentId,
211 ),
212 eq(
213 relayEnvironmentLinks.environmentPublicKey,
214 relayEnvironmentCredentials.environmentPublicKey,
215 ),
216 isNull(relayEnvironmentLinks.revokedAt),
217 ),
218 ),
219 ),
220 ),
221 )
222 .limit(1)
223 .pipe(
224 Effect.mapError(
225 (cause) =>
226 new EnvironmentCredentialAuthenticatePersistenceError({
227 stage: "lookup-credential",
228 cause,
229 }),
230 ),
231 );
232 const row = rows[0];
233 if (row) {
234 yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId });
235 }
236 return row
237 ? Option.some({
238 credentialId: row.credentialId,
239 environmentId: row.environmentId,
240 environmentPublicKey: row.environmentPublicKey,
241 })
242 : Option.none();
243 }),
244
245 revokeForEnvironmentPublicKey: Effect.fn(
246 "relay.environment_credentials.revoke_for_environment_public_key",
247 )(function* (input) {
248 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
249 const revokedAt = DateTime.formatIso(yield* DateTime.now);
250 const rows = yield* db
251 .update(relayEnvironmentCredentials)
252 .set({
253 revokedAt,
254 updatedAt: revokedAt,
255 })
256 .where(
257 and(
258 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
259 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
260 isNull(relayEnvironmentCredentials.revokedAt),
261 notExists(
262 new QueryBuilder()
263 .select({ userId: relayEnvironmentLinks.userId })
264 .from(relayEnvironmentLinks)
265 .where(
266 and(
267 eq(relayEnvironmentLinks.environmentId, input.environmentId),
268 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
269 isNull(relayEnvironmentLinks.revokedAt),
270 ),
271 ),
272 ),
273 ),
274 )
275 .returning({
276 credentialId: relayEnvironmentCredentials.credentialId,
277 })
278 .pipe(
279 Effect.mapError(
280 (cause) =>
281 new EnvironmentCredentialRevokePersistenceError({
282 environmentId: input.environmentId,
283 cause,
284 }),
285 ),
286 );
287 return rows.length > 0;
288 }),
289 });
290});
291
292export const layer = Layer.effect(EnvironmentCredentials, make);