| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as HttpClient from "effect/http/HttpClient"; |
| 5 | import * 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 | |
| 18 | const CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"; |
| 19 | const ISSUERS = new Set(["https://accounts.google.com", "accounts.google.com"]); |
| 20 | const CLOCK_SKEW_SECONDS = 60; |
| 21 | |
| 22 | interface Jwk { |
| 23 | kid: string; |
| 24 | kty: string; |
| 25 | n: string; |
| 26 | e: string; |
| 27 | alg?: string; |
| 28 | } |
| 29 | |
| 30 | interface 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 | */ |
| 43 | export class GoogleSigningKeysUnavailable extends Data.TaggedError( |
| 44 | "GCP.GoogleSigningKeysUnavailable", |
| 45 | )<{ message: string }> {} |
| 46 | |
| 47 | const keysUnavailable = (cause: unknown) => |
| 48 | new GoogleSigningKeysUnavailable({ message: String(cause) }); |
| 49 | |
| 50 | let certs: { keys: Map<string, CryptoKey>; fetchedAt: number } | undefined; |
| 51 | |
| 52 | /** A JWT segment's JSON, or `undefined` when it isn't valid base64url JSON. */ |
| 53 | const decodeSegment = (segment: string): unknown => { |
| 54 | try { |
| 55 | return JSON.parse(new TextDecoder().decode(base64UrlBytes(segment))); |
| 56 | } catch { |
| 57 | return undefined; |
| 58 | } |
| 59 | }; |
| 60 | |
| 61 | const 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 | |
| 71 | const 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 | */ |
| 110 | export 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. */ |
| 163 | export 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 | }; |