1import * as Clock from "effect/Clock";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as HttpClient from "effect/http/HttpClient";
5import * as HttpClientRequest from "effect/http/HttpClientRequest";
6
7/**
8 * Verification of the Google-signed OIDC ID tokens that Pub/Sub push
9 * subscriptions and Cloud Scheduler attach to requests they deliver to a
10 * Cloud Run service or Cloud Function. Cloud Run only enforces them when
11 * the service requires authentication, so event sources verify every
12 * delivery themselves: public services stay safe, and a token minted for
13 * one subscription cannot be replayed against another path.
14 *
15 * NOT exported from `index.ts`.
16 */
17
18const CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
19const ISSUERS = new Set(["https://accounts.google.com", "accounts.google.com"]);
20const CLOCK_SKEW_SECONDS = 60;
21
22interface Jwk {
23 kid: string;
24 kty: string;
25 n: string;
26 e: string;
27 alg?: string;
28}
29
30interface Claims {
31 iss?: string;
32 aud?: string;
33 email?: string;
34 email_verified?: boolean;
35 exp?: number;
36 iat?: number;
37}
38
39/**
40 * Google's signing keys could not be fetched or imported. Deliveries should
41 * be retried (answer 5xx), not rejected as unauthorized.
42 */
43export class GoogleSigningKeysUnavailable extends Data.TaggedError(
44 "GCP.GoogleSigningKeysUnavailable",
45)<{ message: string }> {}
46
47const keysUnavailable = (cause: unknown) =>
48 new GoogleSigningKeysUnavailable({ message: String(cause) });
49
50let certs: { keys: Map<string, CryptoKey>; fetchedAt: number } | undefined;
51
52/** A JWT segment's JSON, or `undefined` when it isn't valid base64url JSON. */
53const decodeSegment = (segment: string): unknown => {
54 try {
55 return JSON.parse(new TextDecoder().decode(base64UrlBytes(segment)));
56 } catch {
57 return undefined;
58 }
59};
60
61const base64UrlBytes = (segment: string): Uint8Array<ArrayBuffer> => {
62 const base64 = segment.replaceAll("-", "+").replaceAll("_", "/");
63 const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
64 const bytes = new Uint8Array(binary.length);
65 for (let index = 0; index < binary.length; index++) {
66 bytes[index] = binary.charCodeAt(index);
67 }
68 return bytes;
69};
70
71const loadKeys = (http: HttpClient.HttpClient, force: boolean) =>
72 Effect.gen(function* () {
73 const now = yield* Clock.currentTimeMillis;
74 if (!force && certs !== undefined && now - certs.fetchedAt < 3_600_000) {
75 return certs.keys;
76 }
77 const response = yield* http
78 .execute(HttpClientRequest.get(CERTS_URL))
79 .pipe(Effect.mapError(keysUnavailable));
80 const body = (yield* response.json.pipe(
81 Effect.mapError(keysUnavailable),
82 )) as { keys?: Jwk[] };
83 const keys = new Map<string, CryptoKey>();
84 for (const jwk of body.keys ?? []) {
85 if (jwk.kty !== "RSA") continue;
86 const key = yield* Effect.tryPromise({
87 try: () =>
88 crypto.subtle.importKey(
89 "jwk",
90 { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
91 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
92 false,
93 ["verify"],
94 ),
95 catch: keysUnavailable,
96 });
97 keys.set(jwk.kid, key);
98 }
99 certs = { keys, fetchedAt: now };
100 return keys;
101 });
102
103/**
104 * True when `authorization` carries a valid Google ID token for `audience`
105 * whose verified email is `email`. A malformed, expired, mis-addressed, or
106 * badly signed token is `false`; failing to load Google's signing keys is a
107 * {@link GoogleSigningKeysUnavailable} error, so callers can ask the sender
108 * to retry.
109 */
110export const verifyGoogleIdToken = (options: {
111 authorization: string | undefined;
112 audience: string;
113 email: string;
114}) =>
115 Effect.gen(function* () {
116 const token = /^Bearer\s+(.+)$/i.exec(options.authorization ?? "")?.[1];
117 if (token === undefined) return false;
118 const [header, payload, signature] = token.split(".");
119 if (!header || !payload || !signature) return false;
120 const { kid, alg } = (decodeSegment(header) ?? {}) as {
121 kid?: string;
122 alg?: string;
123 };
124 if (alg !== "RS256" || kid === undefined) return false;
125 const claims = decodeSegment(payload) as Claims | undefined;
126 if (claims === undefined) return false;
127 let signatureBytes: Uint8Array<ArrayBuffer>;
128 try {
129 signatureBytes = base64UrlBytes(signature);
130 } catch {
131 return false;
132 }
133
134 const http = yield* HttpClient.HttpClient;
135 let key = (yield* loadKeys(http, false)).get(kid);
136 // Google rotates signing keys; refetch once for an unknown `kid`.
137 if (key === undefined) key = (yield* loadKeys(http, true)).get(kid);
138 if (key === undefined) return false;
139 const valid = yield* Effect.tryPromise({
140 try: () =>
141 crypto.subtle.verify(
142 "RSASSA-PKCS1-v1_5",
143 key,
144 signatureBytes,
145 new TextEncoder().encode(`${header}.${payload}`),
146 ),
147 catch: keysUnavailable,
148 });
149 if (!valid) return false;
150
151 const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
152 return (
153 ISSUERS.has(claims.iss ?? "") &&
154 claims.aud === options.audience &&
155 claims.email === options.email &&
156 claims.email_verified === true &&
157 (claims.exp ?? 0) + CLOCK_SKEW_SECONDS > now &&
158 (claims.iat ?? Number.POSITIVE_INFINITY) - CLOCK_SKEW_SECONDS <= now
159 );
160 });
161
162/** The `aud` claim of a bearer token, unverified — for diagnostics only. */
163export const unverifiedAudience = (authorization: string | undefined) => {
164 try {
165 const token = /^Bearer\s+(.+)$/i.exec(authorization ?? "")?.[1];
166 const payload = token?.split(".")[1];
167 return payload ? (decodeSegment(payload) as Claims).aud : undefined;
168 } catch {
169 return undefined;
170 }
171};