1import * as agw2 from "@distilled.cloud/aws/apigatewayv2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { toWireSeconds } from "../../Util/Duration.ts";
10import type { Providers } from "../Providers.ts";
11import type { Api } from "./Api.ts";
12import { collectAllPages, retryOnTooManyRequests } from "./common.ts";
13
14export interface AuthorizerProps {
15 /**
16 * ID of the API this authorizer belongs to. Usually derived from
17 * `api.apiId` by the {@link Authorizer} wrapper.
18 */
19 apiId: string;
20 /**
21 * The authorizer type: `JWT` (HTTP APIs — validate a JWT issued by an
22 * OIDC/OAuth2 provider) or `REQUEST` (Lambda authorizer, both
23 * protocols).
24 */
25 authorizerType: "JWT" | "REQUEST";
26 /**
27 * Name of the authorizer. If omitted, Alchemy generates a deterministic
28 * physical name.
29 */
30 name?: string;
31 /**
32 * The identity source(s):
33 *
34 * - JWT — where to find the token, e.g. `["$request.header.Authorization"]`.
35 * - REQUEST (HTTP APIs) — the caching identity sources.
36 * - REQUEST (WebSocket APIs) — e.g. `["route.request.header.Auth"]`.
37 */
38 identitySource?: string[];
39 /**
40 * JWT configuration (`JWT` authorizers only): the token `Issuer` URL and
41 * accepted `Audience` values.
42 */
43 jwtConfiguration?: agw2.JWTConfiguration;
44 /**
45 * The authorizer's Lambda invocation URI (`REQUEST` authorizers only):
46 * `arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{functionArn}/invocations`.
47 */
48 authorizerUri?: string;
49 /**
50 * The payload format version the Lambda authorizer receives (`REQUEST`
51 * on HTTP APIs only): `1.0` or `2.0`.
52 */
53 authorizerPayloadFormatVersion?: string;
54 /**
55 * Whether a `REQUEST` authorizer (HTTP APIs, payload 2.0) returns simple
56 * `{ isAuthorized: boolean }` responses instead of IAM policies.
57 */
58 enableSimpleResponses?: boolean;
59 /**
60 * TTL for cached authorizer results (`REQUEST` only), e.g. `"5 minutes"`
61 * or `Duration.seconds(300)` (a bare number is milliseconds). Rounded to
62 * whole seconds on the wire (`AuthorizerResultTtlInSeconds`).
63 * @default 300 seconds
64 */
65 authorizerResultTtl?: Duration.Input;
66 /**
67 * IAM role ARN API Gateway assumes to invoke the authorizer Lambda.
68 * Omit to use a Lambda resource policy (`Lambda.Permission`) instead.
69 */
70 authorizerCredentialsArn?: string;
71 /**
72 * Validation expression for the incoming identity (WebSocket `REQUEST`
73 * authorizers only).
74 */
75 identityValidationExpression?: string;
76}
77
78export interface AuthorizerType extends Resource<
79 "AWS.ApiGatewayV2.Authorizer",
80 AuthorizerProps,
81 {
82 /** The API this authorizer belongs to. */
83 apiId: string;
84 /** The authorizer identifier — referenced by `Route.authorizerId`. */
85 authorizerId: string;
86 /** The authorizer name. */
87 name: string;
88 authorizerType: string;
89 identitySource: string[] | undefined;
90 jwtConfiguration: agw2.JWTConfiguration | undefined;
91 authorizerUri: string | undefined;
92 authorizerPayloadFormatVersion: string | undefined;
93 enableSimpleResponses: boolean | undefined;
94 authorizerResultTtlInSeconds: number | undefined;
95 authorizerCredentialsArn: string | undefined;
96 identityValidationExpression: string | undefined;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An API Gateway v2 Authorizer — controls access to HTTP/WebSocket API
104 * routes via JWT validation or a Lambda (`REQUEST`) authorizer.
105 * ### JWT authorizers
106 * The common HTTP API authorizer: API Gateway validates the caller's JWT
107 * against the issuer's JWKS and matches the audience — no Lambda invoked.
108 *
109 * **Example:** JWT authorizer for a Cognito user pool
110 * ```typescript
111 * const authorizer = yield* ApiGatewayV2.Authorizer("Jwt", {
112 * api,
113 * authorizerType: "JWT",
114 * identitySource: ["$request.header.Authorization"],
115 * jwtConfiguration: {
116 * Issuer: `https://cognito-idp.us-west-2.amazonaws.com/${userPoolId}`,
117 * Audience: [clientId],
118 * },
119 * });
120 *
121 * yield* ApiGatewayV2.Route("Secure", {
122 * api,
123 * routeKey: "GET /me",
124 * integration,
125 * authorizationType: "JWT",
126 * authorizerId: authorizer.authorizerId,
127 * });
128 * ```
129 *
130 * ### Lambda (REQUEST) authorizers
131 * **Example:** Simple-response Lambda authorizer
132 * ```typescript
133 * const authorizer = yield* ApiGatewayV2.Authorizer("Lambda", {
134 * api,
135 * authorizerType: "REQUEST",
136 * identitySource: ["$request.header.Authorization"],
137 * authorizerUri: invocationUri,
138 * authorizerPayloadFormatVersion: "2.0",
139 * enableSimpleResponses: true,
140 * });
141 * ```
142 *
143 * @resource
144 */
145export const AuthorizerResource = Resource<AuthorizerType>(
146 "AWS.ApiGatewayV2.Authorizer",
147);
148
149export interface AuthorizerInputProps extends Omit<
150 {
151 [K in keyof AuthorizerProps]?: Input<AuthorizerProps[K]>;
152 },
153 "apiId" | "authorizerType"
154> {
155 /**
156 * The `Api` this authorizer belongs to (preferred). Alternatively pass a
157 * raw `apiId`.
158 */
159 api?: Api;
160 apiId?: Input<string>;
161 authorizerType: Input<"JWT" | "REQUEST">;
162}
163
164/**
165 * User-facing wrapper for the Authorizer resource. Accepts `api: Api` as
166 * the idiomatic way to attach an authorizer to an API.
167 */
168export const Authorizer = (id: string, props: AuthorizerInputProps) =>
169 Effect.gen(function* () {
170 const { api, ...rest } = props;
171 const apiId = rest.apiId ?? api?.apiId;
172 if (!apiId) {
173 return yield* Effect.die(
174 "Authorizer requires either `api` (preferred) or an explicit `apiId`.",
175 );
176 }
177 return yield* AuthorizerResource(id, { ...rest, apiId } as any);
178 });
179
180const snapshotFromAuthorizer = (
181 apiId: string,
182 auth: agw2.GetAuthorizerResponse,
183): AuthorizerType["Attributes"] => ({
184 apiId,
185 authorizerId: auth.AuthorizerId!,
186 name: auth.Name ?? "",
187 authorizerType: auth.AuthorizerType ?? "JWT",
188 identitySource: auth.IdentitySource,
189 jwtConfiguration: auth.JwtConfiguration,
190 authorizerUri: auth.AuthorizerUri,
191 authorizerPayloadFormatVersion: auth.AuthorizerPayloadFormatVersion,
192 enableSimpleResponses: auth.EnableSimpleResponses,
193 authorizerResultTtlInSeconds: auth.AuthorizerResultTtlInSeconds,
194 authorizerCredentialsArn: auth.AuthorizerCredentialsArn,
195 identityValidationExpression: auth.IdentityValidationExpression,
196});
197
198export const AuthorizerProvider = () =>
199 Provider.effect(
200 AuthorizerResource,
201 Effect.gen(function* () {
202 const createName = Effect.fn(function* (
203 id: string,
204 props: Pick<AuthorizerProps, "name">,
205 ) {
206 return (
207 props.name ?? (yield* createPhysicalName({ id, maxLength: 128 }))
208 );
209 });
210
211 const getAuthorizerSafe = (apiId: string, authorizerId: string) =>
212 agw2
213 .getAuthorizer({ ApiId: apiId, AuthorizerId: authorizerId })
214 .pipe(
215 Effect.catchTag("NotFoundException", () =>
216 Effect.succeed(undefined),
217 ),
218 );
219
220 return AuthorizerResource.Provider.of({
221 stables: ["apiId", "authorizerId"],
222
223 list: () =>
224 Effect.gen(function* () {
225 const apis = yield* collectAllPages((NextToken) =>
226 agw2.getApis({ NextToken }),
227 );
228 const perApi = yield* Effect.forEach(
229 apis.filter((api) => api.ApiId != null),
230 (api) =>
231 collectAllPages((NextToken) =>
232 agw2.getAuthorizers({ ApiId: api.ApiId!, NextToken }),
233 ).pipe(
234 Effect.map((items) =>
235 items
236 .filter((auth) => auth.AuthorizerId != null)
237 .map((auth) => snapshotFromAuthorizer(api.ApiId!, auth)),
238 ),
239 Effect.catchTag("NotFoundException", () =>
240 Effect.succeed([] as AuthorizerType["Attributes"][]),
241 ),
242 ),
243 { concurrency: 5 },
244 );
245 return perApi.flat();
246 }),
247
248 read: Effect.fn(function* ({ output }) {
249 if (!output?.apiId || !output.authorizerId) return undefined;
250 const auth = yield* getAuthorizerSafe(
251 output.apiId,
252 output.authorizerId,
253 );
254 if (!auth?.AuthorizerId) return undefined;
255 return snapshotFromAuthorizer(output.apiId, auth);
256 }),
257
258 diff: Effect.fn(function* ({ news, olds }) {
259 if (!isResolved(news)) return undefined;
260 if (news.apiId !== olds.apiId) {
261 return { action: "replace" } as const;
262 }
263 }),
264
265 reconcile: Effect.fn(function* ({ id, news, output, session }) {
266 const apiId = output?.apiId ?? news.apiId;
267 const name = yield* createName(id, news);
268
269 // 1. OBSERVE
270 let observed = output?.authorizerId
271 ? yield* getAuthorizerSafe(apiId, output.authorizerId)
272 : undefined;
273
274 // 2. ENSURE
275 if (!observed?.AuthorizerId) {
276 observed = yield* retryOnTooManyRequests(
277 agw2.createAuthorizer({
278 ApiId: apiId,
279 Name: name,
280 AuthorizerType: news.authorizerType,
281 IdentitySource: news.identitySource,
282 JwtConfiguration: news.jwtConfiguration,
283 AuthorizerUri: news.authorizerUri,
284 AuthorizerPayloadFormatVersion:
285 news.authorizerPayloadFormatVersion,
286 EnableSimpleResponses: news.enableSimpleResponses,
287 AuthorizerResultTtlInSeconds: toWireSeconds(
288 news.authorizerResultTtl,
289 ),
290 AuthorizerCredentialsArn: news.authorizerCredentialsArn,
291 IdentityValidationExpression: news.identityValidationExpression,
292 }),
293 );
294 yield* session.note(`Created authorizer ${observed.AuthorizerId}`);
295 return snapshotFromAuthorizer(apiId, observed);
296 }
297
298 // 3. SYNC — update on drift.
299 const snapshot = snapshotFromAuthorizer(apiId, observed);
300 const desiredTtlSeconds = toWireSeconds(news.authorizerResultTtl);
301 const drift =
302 snapshot.name !== name ||
303 snapshot.authorizerType !== news.authorizerType ||
304 (news.identitySource !== undefined &&
305 !deepEqual(snapshot.identitySource, news.identitySource)) ||
306 (news.jwtConfiguration !== undefined &&
307 !deepEqual(snapshot.jwtConfiguration, news.jwtConfiguration)) ||
308 snapshot.authorizerUri !== news.authorizerUri ||
309 snapshot.authorizerPayloadFormatVersion !==
310 news.authorizerPayloadFormatVersion ||
311 (news.enableSimpleResponses !== undefined &&
312 snapshot.enableSimpleResponses !== news.enableSimpleResponses) ||
313 (desiredTtlSeconds !== undefined &&
314 snapshot.authorizerResultTtlInSeconds !== desiredTtlSeconds) ||
315 snapshot.authorizerCredentialsArn !==
316 news.authorizerCredentialsArn ||
317 snapshot.identityValidationExpression !==
318 news.identityValidationExpression;
319 if (drift) {
320 const updated = yield* retryOnTooManyRequests(
321 agw2.updateAuthorizer({
322 ApiId: apiId,
323 AuthorizerId: snapshot.authorizerId,
324 Name: name,
325 AuthorizerType: news.authorizerType,
326 IdentitySource: news.identitySource,
327 JwtConfiguration: news.jwtConfiguration,
328 AuthorizerUri: news.authorizerUri,
329 AuthorizerPayloadFormatVersion:
330 news.authorizerPayloadFormatVersion,
331 EnableSimpleResponses: news.enableSimpleResponses,
332 AuthorizerResultTtlInSeconds: desiredTtlSeconds,
333 AuthorizerCredentialsArn: news.authorizerCredentialsArn,
334 IdentityValidationExpression: news.identityValidationExpression,
335 }),
336 );
337 yield* session.note(`Updated authorizer ${snapshot.authorizerId}`);
338 return snapshotFromAuthorizer(apiId, updated);
339 }
340
341 return snapshot;
342 }),
343
344 delete: Effect.fn(function* ({ output, session }) {
345 yield* retryOnTooManyRequests(
346 agw2
347 .deleteAuthorizer({
348 ApiId: output.apiId,
349 AuthorizerId: output.authorizerId,
350 })
351 .pipe(Effect.catchTag("NotFoundException", () => Effect.void)),
352 );
353 yield* session.note(`Deleted authorizer ${output.authorizerId}`);
354 }),
355 });
356 }),
357 );