Skip to content

Commit 75df098

Browse files
auth using middleware api key
1 parent 7457564 commit 75df098

File tree

8 files changed

+273
-111
lines changed

8 files changed

+273
-111
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
4. Access: Collaborator inputs workshopId and accessToken, downloads data.
88
5. Sync: Clients edit locally, sync with server using their token.
99
6. Revoke: Owner revokes access, collaborator’s token fails.
10-
7. Expiry: Client handles token expiration gracefully.
10+
7. Expiry: Client handles token expiration gracefully.
11+
12+
> Allow user to check all changes (syncs) by selecting history by `workspaceId` (`"read"` scope?)

apps/client/src/lib/services/sync.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,9 @@ export class Sync extends Effect.Service<Sync>()("Sync", {
3636
Effect.flatMap((token) =>
3737
client.syncData
3838
.push({
39-
// headers: { Authorization: `Bearer ${token}` },
40-
path: {
41-
workspaceId: workspace.workspaceId,
42-
},
43-
payload: { clientId, snapshot, snapshotId },
39+
headers: { "x-api-key": token },
40+
path: { workspaceId: workspace.workspaceId },
41+
payload: { snapshot, snapshotId },
4442
})
4543
.pipe(
4644
Effect.map((response) => ({

apps/client/src/workers/live.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ const WorkerLive = WorkerRunner.layer((params: LiveQuery) =>
101101
Effect.gen(function* () {
102102
yield* Effect.log("Startup live query connection");
103103

104-
yield* Effect.fork(main({ workspaceId: params.workspaceId }));
105-
yield* Effect.forever(Effect.never);
104+
yield* Effect.addFinalizer(() =>
105+
Effect.log("Closed live query connection")
106+
);
106107

107-
yield* Effect.log("Closed live query connection");
108+
yield* Effect.fork(main({ workspaceId: params.workspaceId }));
109+
yield* Effect.never;
108110
}).pipe(Effect.mapError(() => "Live query error"))
109111
)
110112
).pipe(Layer.provide(BrowserWorkerRunner.layer));

apps/server/src/group/sync-auth.ts

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,96 @@
11
import { HttpApiBuilder } from "@effect/platform";
22
import { SyncApi, type Scope } from "@local/sync";
3-
import { Config, Effect, Layer, Redacted, Schema } from "effect";
4-
import * as jwt from "jsonwebtoken";
5-
import { workspaceTable } from "../db/schema";
3+
import { eq } from "drizzle-orm";
4+
import { Effect, Layer, Schema } from "effect";
5+
import { tokenTable, workspaceTable } from "../db/schema";
66
import { Drizzle } from "../drizzle";
7-
8-
interface TokenPayload {
9-
iat: number; // Issued at (Unix timestamp)
10-
exp?: number; // Expiration (optional for master tokens)
11-
sub: string; // Client ID
12-
workspaceId: string; // Workshop ID
13-
scope: typeof Scope.Type; // Permission scope
14-
isMaster: boolean; // Master token flag
15-
}
7+
import { AuthorizationLive } from "../middleware/authorization";
8+
import { Jwt } from "../services/jwt";
169

1710
export const SyncAuthGroupLive = HttpApiBuilder.group(
1811
SyncApi,
1912
"syncAuth",
2013
(handlers) =>
2114
Effect.gen(function* () {
22-
const secretKey = yield* Config.redacted("JWT_SECRET");
15+
const jwt = yield* Jwt;
2316
const { query } = yield* Drizzle;
2417

2518
return handlers
2619
.handle("generateToken", ({ payload }) =>
2720
Effect.gen(function* () {
28-
const tokenPayload = {
29-
iat: Math.floor(Date.now() / 1000), // Current timestamp in seconds
30-
sub: payload.clientId,
31-
workspaceId: payload.workspaceId,
32-
scope: "read_write", // TODO
33-
isMaster: true, // TODO
34-
} satisfies TokenPayload;
21+
const scope: typeof Scope.Type = "read_write";
22+
const isMaster = true;
23+
const issuedAt = new Date();
3524

36-
yield* Effect.log(tokenPayload);
25+
yield* query({
26+
Request: Schema.Struct({ workspaceId: Schema.String }),
27+
execute: (db, { workspaceId }) =>
28+
db
29+
.select()
30+
.from(workspaceTable)
31+
.where(eq(workspaceTable.workspaceId, workspaceId)),
32+
})({ workspaceId: payload.workspaceId }).pipe(
33+
Effect.flatMap((rows) =>
34+
rows.length === 0
35+
? Effect.void
36+
: Effect.fail({ message: "Workspace already exists" })
37+
)
38+
);
3739

38-
const token = jwt.sign(tokenPayload, Redacted.value(secretKey), {
39-
algorithm: "HS256",
40+
const token = jwt.sign({
41+
clientId: payload.clientId,
42+
workspaceId: payload.workspaceId,
4043
});
4144

42-
yield* query({
43-
Request: Schema.Struct({
44-
clientId: Schema.String,
45-
workspaceId: Schema.String,
46-
snapshot: Schema.Uint8ArrayFromSelf,
45+
yield* Effect.all([
46+
query({
47+
Request: Schema.Struct({
48+
clientId: Schema.String,
49+
workspaceId: Schema.String,
50+
snapshot: Schema.Uint8ArrayFromSelf,
51+
}),
52+
execute: (db, { clientId, snapshot, workspaceId }) =>
53+
db.insert(workspaceTable).values({
54+
snapshot,
55+
clientId,
56+
workspaceId,
57+
ownerClientId: clientId,
58+
snapshotId: payload.snapshotId,
59+
}),
60+
})({
61+
clientId: payload.clientId,
62+
snapshot: payload.snapshot,
63+
workspaceId: payload.workspaceId,
4764
}),
48-
execute: (db, { clientId, snapshot, workspaceId }) =>
49-
db.insert(workspaceTable).values({
50-
snapshot,
51-
clientId,
52-
workspaceId,
53-
ownerClientId: clientId,
54-
snapshotId: payload.snapshotId,
65+
query({
66+
Request: Schema.Struct({
67+
clientId: Schema.String,
68+
workspaceId: Schema.String,
69+
tokenValue: Schema.String,
5570
}),
56-
})({
57-
clientId: payload.clientId,
58-
snapshot: payload.snapshot,
59-
workspaceId: payload.workspaceId,
60-
});
71+
execute: (db, { clientId, tokenValue, workspaceId }) =>
72+
db.insert(tokenTable).values({
73+
clientId,
74+
scope,
75+
tokenValue,
76+
workspaceId,
77+
isMaster,
78+
issuedAt,
79+
expiresAt: null,
80+
revokedAt: null,
81+
}),
82+
})({
83+
clientId: payload.clientId,
84+
tokenValue: token,
85+
workspaceId: payload.workspaceId,
86+
}),
87+
]);
6188

6289
return {
6390
token,
6491
workspaceId: payload.workspaceId,
6592
snapshot: payload.snapshot,
66-
createdAt: new Date(),
93+
createdAt: issuedAt,
6794
};
6895
}).pipe(
6996
Effect.tapErrorCause(Effect.logError),
@@ -80,4 +107,4 @@ export const SyncAuthGroupLive = HttpApiBuilder.group(
80107
Effect.fail("Not implemented")
81108
);
82109
})
83-
).pipe(Layer.provide(Drizzle.Default));
110+
).pipe(Layer.provide([Drizzle.Default, AuthorizationLive, Jwt.Default]));

apps/server/src/group/sync-data.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,28 @@
11
import { HttpApiBuilder } from "@effect/platform";
2-
import { SyncApi } from "@local/sync";
2+
import { AuthWorkspace, SyncApi } from "@local/sync";
33
import { SnapshotToLoroDoc } from "@local/sync/loro";
4-
import { and, desc, eq } from "drizzle-orm";
54
import { Array, Effect, Layer, Schema } from "effect";
65
import { workspaceTable } from "../db/schema";
76
import { Drizzle } from "../drizzle";
7+
import { AuthorizationLive } from "../middleware/authorization";
88

99
export const SyncDataGroupLive = HttpApiBuilder.group(
1010
SyncApi,
1111
"syncData",
1212
(handlers) =>
1313
Effect.gen(function* () {
1414
const { query } = yield* Drizzle;
15+
1516
return handlers
1617
.handle(
1718
"push",
18-
({
19-
payload: { clientId, snapshot, snapshotId },
20-
path: { workspaceId },
21-
}) =>
19+
({ payload: { snapshot, snapshotId }, path: { workspaceId } }) =>
2220
Effect.gen(function* () {
21+
const workspace = yield* AuthWorkspace;
2322
const doc = yield* Schema.decode(SnapshotToLoroDoc)(snapshot);
2423

2524
yield* Effect.log(`Pushing workspace ${workspaceId}`);
2625

27-
const workspace = yield* query({
28-
Request: Schema.Struct({
29-
workspaceId: Schema.UUID,
30-
clientId: Schema.UUID,
31-
}),
32-
execute: (db, { workspaceId, clientId }) =>
33-
db
34-
.select()
35-
.from(workspaceTable)
36-
.where(
37-
and(
38-
eq(workspaceTable.workspaceId, workspaceId),
39-
eq(workspaceTable.clientId, clientId)
40-
)
41-
)
42-
.orderBy(desc(workspaceTable.createdAt))
43-
.limit(1),
44-
})({ clientId, workspaceId }).pipe(Effect.flatMap(Array.head));
45-
4626
doc.import(workspace.snapshot); // 🪄
4727

4828
const newSnapshot = yield* Schema.encode(SnapshotToLoroDoc)(doc);
@@ -78,4 +58,4 @@ export const SyncDataGroupLive = HttpApiBuilder.group(
7858
Effect.fail("Not implemented")
7959
);
8060
})
81-
).pipe(Layer.provide(Drizzle.Default));
61+
).pipe(Layer.provide([Drizzle.Default, AuthorizationLive]));
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
Authorization,
3+
DatabaseError,
4+
MissingWorkspace,
5+
Unauthorized,
6+
} from "@local/sync";
7+
import { and, desc, eq } from "drizzle-orm";
8+
import { Array, Effect, Layer, Match, Redacted, Schema } from "effect";
9+
import { workspaceTable } from "../db/schema";
10+
import { Drizzle } from "../drizzle";
11+
import { Jwt } from "../services/jwt";
12+
13+
export const AuthorizationLive = Layer.effect(
14+
Authorization,
15+
Effect.gen(function* () {
16+
const jwt = yield* Jwt;
17+
const { query } = yield* Drizzle;
18+
yield* Effect.log("Creating Authorization middleware");
19+
return {
20+
apiKey: (apiKey) =>
21+
Effect.gen(function* () {
22+
yield* Effect.log("Api key", Redacted.value(apiKey));
23+
24+
const tokenPayload = yield* jwt.decode({ apiKey });
25+
26+
yield* Effect.log(`Valid auth ${tokenPayload.workspaceId}`);
27+
28+
return yield* query({
29+
Request: Schema.Struct({
30+
workspaceId: Schema.UUID,
31+
clientId: Schema.UUID,
32+
}),
33+
execute: (db, { workspaceId, clientId }) =>
34+
db
35+
.select()
36+
.from(workspaceTable)
37+
.where(
38+
and(
39+
eq(workspaceTable.workspaceId, workspaceId),
40+
eq(workspaceTable.clientId, clientId)
41+
)
42+
)
43+
.orderBy(desc(workspaceTable.createdAt))
44+
.limit(1),
45+
})({
46+
clientId: tokenPayload.sub,
47+
workspaceId: tokenPayload.workspaceId,
48+
}).pipe(Effect.flatMap(Array.head));
49+
}).pipe(
50+
Effect.mapError((error) =>
51+
Match.value(error).pipe(
52+
Match.tagsExhaustive({
53+
NoSuchElementException: () => new MissingWorkspace(),
54+
JwtError: () => new Unauthorized(),
55+
ParseError: () => new Unauthorized(),
56+
QueryError: () => new DatabaseError(),
57+
})
58+
)
59+
)
60+
),
61+
};
62+
})
63+
).pipe(Layer.provide([Drizzle.Default, Jwt.Default]));

apps/server/src/services/jwt.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Scope } from "@local/sync";
2+
import { Config, Data, Effect, Redacted, Schema } from "effect";
3+
import * as jwt from "jsonwebtoken";
4+
5+
class TokenPayload extends Schema.Class<TokenPayload>("TokenPayload")({
6+
iat: Schema.Number,
7+
exp: Schema.optional(Schema.Number),
8+
sub: Schema.String,
9+
workspaceId: Schema.String,
10+
scope: Scope,
11+
isMaster: Schema.Boolean,
12+
}) {}
13+
14+
class JwtError extends Data.TaggedError("JwtError")<{
15+
reason: "missing" | "invalid";
16+
}> {}
17+
18+
export class Jwt extends Effect.Service<Jwt>()("Jwt", {
19+
effect: Effect.gen(function* () {
20+
const secretKey = yield* Config.redacted("JWT_SECRET");
21+
return {
22+
sign: ({
23+
clientId,
24+
workspaceId,
25+
}: {
26+
clientId: string;
27+
workspaceId: string;
28+
}) =>
29+
jwt.sign(
30+
new TokenPayload({
31+
iat: Math.floor(Date.now() / 1000),
32+
sub: clientId,
33+
workspaceId,
34+
scope: "read_write",
35+
isMaster: true,
36+
}),
37+
Redacted.value(secretKey),
38+
{ algorithm: "HS256" }
39+
),
40+
41+
decode: ({ apiKey }: { apiKey: Redacted.Redacted<string> }) =>
42+
Effect.gen(function* () {
43+
const decoded = jwt.decode(Redacted.value(apiKey));
44+
45+
if (decoded === null) {
46+
return yield* new JwtError({ reason: "missing" });
47+
}
48+
49+
return yield* Schema.decodeUnknown(TokenPayload)(decoded).pipe(
50+
Effect.mapError(() => new JwtError({ reason: "invalid" }))
51+
);
52+
}),
53+
};
54+
}),
55+
}) {}

0 commit comments

Comments
 (0)