1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5
6import * as WebCrypto from "../WebCrypto.ts";
7
8const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
9
10export class FcmAssertionSigningError extends Schema.TaggedError<FcmAssertionSigningError>()(
11 "FcmAssertionSigningError",
12 { cause: Schema.Defect() },
13) {
14 override get message() {
15 return "Failed to sign Firebase authorization assertion.";
16 }
17}
18
19function base64Url(bytes: Uint8Array): string {
20 return btoa(String.fromCharCode(...bytes))
21 .replaceAll("+", "-")
22 .replaceAll("/", "_")
23 .replace(/=+$/, "");
24}
25
26export class FcmAssertionSigner extends Context.Service<
27 FcmAssertionSigner,
28 {
29 readonly sign: (input: {
30 readonly privateKey: string;
31 readonly clientEmail: string;
32 readonly issuedAt: number;
33 }) => Effect.Effect<string, FcmAssertionSigningError>;
34 }
35>()("t3code-relay/agentActivity/FcmAssertionSigner") {}
36
37export const make = Effect.gen(function* () {
38 const { subtle } = yield* WebCrypto.WebCrypto;
39 return FcmAssertionSigner.of({
40 sign: Effect.fn("relay.fcm.assertion")(function* (input) {
41 return yield* Effect.tryPromise({
42 try: async () => {
43 const encoder = new TextEncoder();
44 const header = base64Url(encoder.encode(encodeJson({ alg: "RS256", typ: "JWT" })));
45 const claims = base64Url(
46 encoder.encode(
47 encodeJson({
48 iss: input.clientEmail,
49 scope: "https://www.googleapis.com/auth/firebase.messaging",
50 aud: "https://oauth2.googleapis.com/token",
51 iat: input.issuedAt,
52 exp: input.issuedAt + 3600,
53 }),
54 ),
55 );
56 const pem = input.privateKey.replace(/-----[^-]+-----|\s/g, "");
57 const key = await subtle.importKey(
58 "pkcs8",
59 Uint8Array.from(atob(pem), (c) => c.charCodeAt(0)),
60 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
61 false,
62 ["sign"],
63 );
64 const signature = await subtle.sign(
65 "RSASSA-PKCS1-v1_5",
66 key,
67 encoder.encode(`${header}.${claims}`),
68 );
69 return `${header}.${claims}.${base64Url(new Uint8Array(signature))}`;
70 },
71 catch: (cause) => new FcmAssertionSigningError({ cause }),
72 });
73 }),
74 });
75});
76export const layer = Layer.effect(FcmAssertionSigner, make);