1import * as ag from "@distilled.cloud/aws/api-gateway";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { toWireSeconds } from "../../Util/Duration.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface AuthorizerProps {
14 /**
15 * REST API identifier that owns the authorizer.
16 */
17 restApiId: Input<string>;
18 /**
19 * Authorizer name.
20 *
21 * If omitted, Alchemy generates a deterministic physical name.
22 */
23 name?: string;
24 /**
25 * Authorizer type.
26 */
27 type: ag.AuthorizerType;
28 /**
29 * Cognito user pool ARNs for `COGNITO_USER_POOLS` authorizers.
30 */
31 providerARNs?: string[];
32 /**
33 * Custom authorization type label.
34 */
35 authType?: string;
36 /**
37 * Lambda invocation URI for `TOKEN` or `REQUEST` authorizers.
38 */
39 authorizerUri?: string;
40 /**
41 * IAM role ARN used by API Gateway to invoke the authorizer.
42 *
43 * This is not secret key material; API Gateway stores the role ARN.
44 */
45 authorizerCredentials?: string;
46 /**
47 * Identity source expression, e.g. `method.request.header.Authorization`.
48 */
49 identitySource?: string;
50 /**
51 * Validation regex for token authorizers.
52 */
53 identityValidationExpression?: string;
54 /**
55 * Cache TTL for authorizer results (e.g. `"5 minutes"` or
56 * `Duration.seconds(300)`; a bare number is milliseconds). Sent to the
57 * API as whole seconds (`authorizerResultTtlInSeconds`).
58 */
59 authorizerResultTtl?: Duration.Input;
60}
61
62/** @resource */
63export interface Authorizer extends Resource<
64 "AWS.ApiGateway.Authorizer",
65 AuthorizerProps,
66 {
67 authorizerId: string;
68 restApiId: string;
69 name: string;
70 type: ag.AuthorizerType;
71 },
72 never,
73 Providers
74> {}
75
76/**
77 * REST API Lambda, Cognito, or gateway authorizer.
78 *
79 * ### Authorizers
80 * **Example:** Lambda TOKEN authorizer
81 * ```typescript
82 * const authorizer = yield* ApiGateway.Authorizer("Auth", {
83 * restApiId: api.restApiId,
84 * type: "TOKEN",
85 * authorizerUri: authorizerInvokeArn,
86 * identitySource: "method.request.header.Authorization",
87 * });
88 * ```
89 */
90const AuthorizerResource = Resource<Authorizer>("AWS.ApiGateway.Authorizer");
91
92export { AuthorizerResource as Authorizer };
93
94const generatedName = (id: string, props: AuthorizerProps) =>
95 props.name
96 ? Effect.succeed(props.name)
97 : createPhysicalName({
98 id,
99 maxLength: 128,
100 });
101
102export const AuthorizerProvider = () =>
103 Provider.effect(
104 AuthorizerResource,
105 Effect.gen(function* () {
106 return {
107 stables: ["authorizerId", "restApiId"] as const,
108 diff: Effect.fn(function* ({ news: newsIn, olds }) {
109 if (!isResolved(newsIn)) return;
110 const news = newsIn as AuthorizerProps;
111 if (
112 // These fields define the authorizer identity and kind; replacement
113 // avoids patching a different authorizer shape in place.
114 news.restApiId !== olds.restApiId ||
115 (news.name !== undefined && news.name !== olds.name) ||
116 news.type !== olds.type
117 ) {
118 return { action: "replace" } as const;
119 }
120 if (!deepEqual(news.providerARNs, olds.providerARNs)) {
121 return { action: "replace" } as const;
122 }
123 if (news.authType !== olds.authType) {
124 return { action: "replace" } as const;
125 }
126 }),
127 read: Effect.fn(function* ({ output }) {
128 if (!output?.authorizerId) return undefined;
129 const a = yield* ag
130 .getAuthorizer({
131 restApiId: output.restApiId,
132 authorizerId: output.authorizerId,
133 })
134 .pipe(
135 Effect.catchTag("NotFoundException", () =>
136 Effect.succeed(undefined),
137 ),
138 );
139 if (!a?.id) return undefined;
140 return {
141 authorizerId: a.id,
142 restApiId: output.restApiId,
143 name: a.name!,
144 type: a.type!,
145 };
146 }),
147 reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) {
148 if (!isResolved(newsIn)) {
149 return yield* Effect.die("Authorizer props were not resolved");
150 }
151 const news = newsIn as Input.ResolveProps<AuthorizerProps>;
152 const name = yield* generatedName(id, news);
153 const restApiId = (output?.restApiId ?? news.restApiId) as string;
154
155 // Observe — fetch the live authorizer if we have a cached id.
156 // The cached id is the only way to find the resource; on a stale
157 // delete out of band we re-create.
158 let observed = output?.authorizerId
159 ? yield* ag
160 .getAuthorizer({
161 restApiId,
162 authorizerId: output.authorizerId,
163 })
164 .pipe(
165 Effect.catchTag("NotFoundException", () =>
166 Effect.succeed(undefined),
167 ),
168 )
169 : undefined;
170
171 // Ensure — create if missing.
172 if (!observed?.id) {
173 const created = yield* ag.createAuthorizer({
174 restApiId: news.restApiId as string,
175 name,
176 type: news.type,
177 providerARNs: news.providerARNs,
178 authType: news.authType,
179 authorizerUri: news.authorizerUri,
180 authorizerCredentials: news.authorizerCredentials,
181 identitySource: news.identitySource,
182 identityValidationExpression: news.identityValidationExpression,
183 authorizerResultTtlInSeconds: toWireSeconds(
184 news.authorizerResultTtl,
185 ),
186 });
187 if (!created.id)
188 return yield* Effect.die("createAuthorizer missing id");
189 yield* session.note(`Created authorizer ${created.id}`);
190 observed = yield* ag.getAuthorizer({
191 restApiId: news.restApiId as string,
192 authorizerId: created.id,
193 });
194 }
195
196 const authorizerId = observed.id!;
197
198 // Sync mutable fields — diff observed cloud state against desired.
199 const patches: ag.PatchOperation[] = [];
200 if (news.authorizerUri !== observed.authorizerUri) {
201 patches.push({
202 op: news.authorizerUri === undefined ? "remove" : "replace",
203 path: "/authorizerUri",
204 value: news.authorizerUri,
205 });
206 }
207 if (news.identitySource !== observed.identitySource) {
208 patches.push({
209 op: news.identitySource === undefined ? "remove" : "replace",
210 path: "/identitySource",
211 value: news.identitySource,
212 });
213 }
214 if (news.authorizerCredentials !== observed.authorizerCredentials) {
215 patches.push({
216 op:
217 news.authorizerCredentials === undefined ? "remove" : "replace",
218 path: "/authorizerCredentials",
219 value: news.authorizerCredentials,
220 });
221 }
222 if (
223 news.identityValidationExpression !==
224 observed.identityValidationExpression
225 ) {
226 patches.push({
227 op:
228 news.identityValidationExpression === undefined
229 ? "remove"
230 : "replace",
231 path: "/identityValidationExpression",
232 value: news.identityValidationExpression,
233 });
234 }
235 const desiredTtlSeconds = toWireSeconds(news.authorizerResultTtl);
236 if (desiredTtlSeconds !== observed.authorizerResultTtlInSeconds) {
237 patches.push({
238 op: desiredTtlSeconds === undefined ? "remove" : "replace",
239 path: "/authorizerResultTtlInSeconds",
240 value:
241 desiredTtlSeconds === undefined
242 ? undefined
243 : String(desiredTtlSeconds),
244 });
245 }
246 if (patches.length > 0) {
247 yield* ag.updateAuthorizer({
248 restApiId,
249 authorizerId,
250 patchOperations: patches,
251 });
252 }
253
254 yield* session.note(`Reconciled authorizer ${authorizerId}`);
255 const final = yield* ag.getAuthorizer({
256 restApiId,
257 authorizerId,
258 });
259 return {
260 authorizerId,
261 restApiId,
262 name: final.name!,
263 type: final.type!,
264 };
265 }),
266 // Authorizers are sub-resources of a RestApi and can only be listed
267 // per-parent (`GET /restapis/{restApiId}/authorizers`). Enumerate every
268 // RestApi (paginated), then fan out the per-api authorizer list.
269 list: () =>
270 Effect.gen(function* () {
271 const restApiIds = yield* ag.getRestApis.pages({}).pipe(
272 Stream.runCollect,
273 Effect.map((chunk) =>
274 Array.from(chunk).flatMap((page) =>
275 (page.items ?? [])
276 .map((api) => api.id)
277 .filter((id): id is string => id != null),
278 ),
279 ),
280 );
281 const rows = yield* Effect.forEach(
282 restApiIds,
283 (restApiId) =>
284 ag.getAuthorizers({ restApiId }).pipe(
285 Effect.map((res) =>
286 (res.items ?? [])
287 .filter(
288 (a): a is ag.Authorizer & { id: string } =>
289 a.id != null,
290 )
291 .map((a) => ({
292 authorizerId: a.id,
293 restApiId,
294 name: a.name!,
295 type: a.type!,
296 })),
297 ),
298 ),
299 { concurrency: 10 },
300 );
301 return rows.flat();
302 }),
303 delete: Effect.fn(function* ({ output, session }) {
304 yield* ag
305 .deleteAuthorizer({
306 restApiId: output.restApiId,
307 authorizerId: output.authorizerId,
308 })
309 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
310 yield* session.note(`Deleted authorizer ${output.authorizerId}`);
311 }),
312 };
313 }),
314 );