| 1 | import type * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import type * as Redacted from "effect/Redacted"; |
| 4 | import { toSeconds } from "../../Util/Duration.ts"; |
| 5 | import type { TargetGroupArn } from "./TargetGroup.ts"; |
| 6 | |
| 7 | /** |
| 8 | * A single forward target with an optional traffic weight. Used by the |
| 9 | * weighted-forward action to split traffic across multiple target groups. |
| 10 | */ |
| 11 | export interface ForwardTarget { |
| 12 | /** |
| 13 | * The target group to forward to. Accepts a `TargetGroup` resource reference |
| 14 | * — the engine resolves it to the ARN automatically (the `Input` machinery |
| 15 | * applies deeply to nested props). |
| 16 | */ |
| 17 | targetGroupArn: TargetGroupArn; |
| 18 | /** |
| 19 | * The weight applied to this target group when splitting traffic. The |
| 20 | * proportion a target group receives is its weight divided by the sum of all |
| 21 | * weights in the action. |
| 22 | * @default 1 |
| 23 | */ |
| 24 | weight?: number; |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * Forward action: routes the request to one or more target groups, optionally |
| 29 | * with session stickiness so a client stays pinned to a single target group. |
| 30 | */ |
| 31 | export interface ForwardAction { |
| 32 | /** Discriminator identifying this as a forward action. */ |
| 33 | type: "forward"; |
| 34 | /** One or more target groups to forward to (weighted). */ |
| 35 | targetGroups: ForwardTarget[]; |
| 36 | /** Session stickiness across the target groups in this action. */ |
| 37 | stickiness?: { |
| 38 | /** Whether target-group stickiness is enabled. */ |
| 39 | enabled: boolean; |
| 40 | /** |
| 41 | * The time a client remains pinned to a target group — e.g. `"1 hour"` |
| 42 | * or `Duration.hours(1)`. Sent to AWS as whole seconds. |
| 43 | * @default "1 hour" |
| 44 | */ |
| 45 | duration?: Duration.Input; |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Redirect action: returns an HTTP redirect (301/302) to a computed URL. Any |
| 51 | * component (protocol, host, path, query, port) may use the reserved keywords |
| 52 | * `#{protocol}`, `#{host}`, `#{path}`, `#{query}`, `#{port}` to copy from the |
| 53 | * original request. |
| 54 | */ |
| 55 | export interface RedirectAction { |
| 56 | /** Discriminator identifying this as a redirect action. */ |
| 57 | type: "redirect"; |
| 58 | /** The redirect status code. */ |
| 59 | statusCode: "HTTP_301" | "HTTP_302"; |
| 60 | /** The protocol (`HTTP`, `HTTPS`, or `#{protocol}`). */ |
| 61 | protocol?: string; |
| 62 | /** The port. */ |
| 63 | port?: string; |
| 64 | /** The hostname. */ |
| 65 | host?: string; |
| 66 | /** The absolute path, starting with `/`. */ |
| 67 | path?: string; |
| 68 | /** The query parameters, not including the leading `?`. */ |
| 69 | query?: string; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Fixed-response action: returns a static HTTP response directly from the load |
| 74 | * balancer without forwarding to any target. |
| 75 | */ |
| 76 | export interface FixedResponseAction { |
| 77 | /** Discriminator identifying this as a fixed-response action. */ |
| 78 | type: "fixedResponse"; |
| 79 | /** The HTTP response status code (2XX/4XX/5XX). */ |
| 80 | statusCode: string; |
| 81 | /** The content type of the response body. */ |
| 82 | contentType?: string; |
| 83 | /** The response body. */ |
| 84 | messageBody?: string; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Authenticate-OIDC action: authenticates the request through an OpenID Connect |
| 89 | * (OIDC) identity provider before forwarding to the next action. |
| 90 | */ |
| 91 | export interface AuthenticateOidcAction { |
| 92 | /** Discriminator identifying this as an authenticate-OIDC action. */ |
| 93 | type: "authenticateOidc"; |
| 94 | /** The OIDC issuer identifier of the identity provider (IdP). */ |
| 95 | issuer: string; |
| 96 | /** The authorization endpoint of the IdP. */ |
| 97 | authorizationEndpoint: string; |
| 98 | /** The token endpoint of the IdP. */ |
| 99 | tokenEndpoint: string; |
| 100 | /** The user info endpoint of the IdP. */ |
| 101 | userInfoEndpoint: string; |
| 102 | /** The OAuth 2.0 client identifier registered with the IdP. */ |
| 103 | clientId: string; |
| 104 | /** Required on first use; omit with `useExistingClientSecret: true` to keep the existing one. */ |
| 105 | clientSecret?: Redacted.Redacted<string>; |
| 106 | /** |
| 107 | * The set of user claims requested from the IdP. |
| 108 | * @default "openid" |
| 109 | */ |
| 110 | scope?: string; |
| 111 | /** |
| 112 | * The name of the cookie used to maintain session information. |
| 113 | * @default "AWSELBAuthSessionCookie" |
| 114 | */ |
| 115 | sessionCookieName?: string; |
| 116 | /** The maximum duration of the authentication session — e.g. `"7 days"`. Sent to AWS as whole seconds. */ |
| 117 | sessionTimeout?: Duration.Input; |
| 118 | /** |
| 119 | * Behavior when the user is not authenticated (`deny`, `allow`, or `authenticate`). |
| 120 | * @default "authenticate" |
| 121 | */ |
| 122 | onUnauthenticatedRequest?: "deny" | "allow" | "authenticate"; |
| 123 | /** Set to `true` on updates to keep the client secret already stored on the action. */ |
| 124 | useExistingClientSecret?: boolean; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Authenticate-Cognito action: authenticates the request through an Amazon |
| 129 | * Cognito user pool before forwarding to the next action. |
| 130 | */ |
| 131 | export interface AuthenticateCognitoAction { |
| 132 | /** Discriminator identifying this as an authenticate-Cognito action. */ |
| 133 | type: "authenticateCognito"; |
| 134 | /** The ARN of the Cognito user pool. */ |
| 135 | userPoolArn: string; |
| 136 | /** The ID of the Cognito user pool client. */ |
| 137 | userPoolClientId: string; |
| 138 | /** The domain prefix or fully-qualified domain of the Cognito user pool. */ |
| 139 | userPoolDomain: string; |
| 140 | /** |
| 141 | * The set of user claims requested from Cognito. |
| 142 | * @default "openid" |
| 143 | */ |
| 144 | scope?: string; |
| 145 | /** |
| 146 | * The name of the cookie used to maintain session information. |
| 147 | * @default "AWSELBAuthSessionCookie" |
| 148 | */ |
| 149 | sessionCookieName?: string; |
| 150 | /** The maximum duration of the authentication session — e.g. `"7 days"`. Sent to AWS as whole seconds. */ |
| 151 | sessionTimeout?: Duration.Input; |
| 152 | /** |
| 153 | * Behavior when the user is not authenticated (`deny`, `allow`, or `authenticate`). |
| 154 | * @default "authenticate" |
| 155 | */ |
| 156 | onUnauthenticatedRequest?: "deny" | "allow" | "authenticate"; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * A listener/rule action. The terminal action of a listener default-action or a |
| 161 | * rule must be `forward`, `redirect`, or `fixedResponse`; authentication |
| 162 | * actions are non-terminal and are ordered before the terminal action. |
| 163 | */ |
| 164 | export type ListenerAction = |
| 165 | | ForwardAction |
| 166 | | RedirectAction |
| 167 | | FixedResponseAction |
| 168 | | AuthenticateOidcAction |
| 169 | | AuthenticateCognitoAction; |
| 170 | |
| 171 | // At reconcile time the engine has already resolved Input/resource refs to |
| 172 | // plain ARN strings. |
| 173 | const resolveTargetGroupArn = (ref: TargetGroupArn): string => ref; |
| 174 | |
| 175 | /** |
| 176 | * Serialize a list of {@link ListenerAction} into the wire `Action[]` shape AWS |
| 177 | * expects, assigning `Order` so authentication actions run before the terminal |
| 178 | * action. Shared by both Listener default-actions and ListenerRule actions. |
| 179 | */ |
| 180 | export const serializeActions = (actions: ListenerAction[]): elbv2.Action[] => |
| 181 | actions.map((action, index): elbv2.Action => { |
| 182 | const Order = index + 1; |
| 183 | switch (action.type) { |
| 184 | case "forward": |
| 185 | return { |
| 186 | Type: "forward", |
| 187 | Order, |
| 188 | ForwardConfig: { |
| 189 | TargetGroups: action.targetGroups.map((t) => ({ |
| 190 | TargetGroupArn: resolveTargetGroupArn(t.targetGroupArn), |
| 191 | Weight: t.weight, |
| 192 | })), |
| 193 | TargetGroupStickinessConfig: action.stickiness |
| 194 | ? { |
| 195 | Enabled: action.stickiness.enabled, |
| 196 | DurationSeconds: toSeconds(action.stickiness.duration), |
| 197 | } |
| 198 | : undefined, |
| 199 | }, |
| 200 | // For a single, unweighted target group, AWS also accepts the legacy |
| 201 | // top-level TargetGroupArn; sending only ForwardConfig is canonical. |
| 202 | TargetGroupArn: |
| 203 | action.targetGroups.length === 1 && |
| 204 | action.targetGroups[0].weight === undefined |
| 205 | ? resolveTargetGroupArn(action.targetGroups[0].targetGroupArn) |
| 206 | : undefined, |
| 207 | }; |
| 208 | case "redirect": |
| 209 | return { |
| 210 | Type: "redirect", |
| 211 | Order, |
| 212 | RedirectConfig: { |
| 213 | StatusCode: action.statusCode, |
| 214 | Protocol: action.protocol, |
| 215 | Port: action.port, |
| 216 | Host: action.host, |
| 217 | Path: action.path, |
| 218 | Query: action.query, |
| 219 | }, |
| 220 | }; |
| 221 | case "fixedResponse": |
| 222 | return { |
| 223 | Type: "fixed-response", |
| 224 | Order, |
| 225 | FixedResponseConfig: { |
| 226 | StatusCode: action.statusCode, |
| 227 | ContentType: action.contentType, |
| 228 | MessageBody: action.messageBody, |
| 229 | }, |
| 230 | }; |
| 231 | case "authenticateOidc": |
| 232 | return { |
| 233 | Type: "authenticate-oidc", |
| 234 | Order, |
| 235 | AuthenticateOidcConfig: { |
| 236 | Issuer: action.issuer, |
| 237 | AuthorizationEndpoint: action.authorizationEndpoint, |
| 238 | TokenEndpoint: action.tokenEndpoint, |
| 239 | UserInfoEndpoint: action.userInfoEndpoint, |
| 240 | ClientId: action.clientId, |
| 241 | // Redacted end-to-end: distilled's AuthenticateOidcActionConfig |
| 242 | // marks ClientSecret sensitive, so the Redacted passes straight |
| 243 | // through and is only unwrapped by the wire serializer. |
| 244 | ClientSecret: action.clientSecret, |
| 245 | Scope: action.scope, |
| 246 | SessionCookieName: action.sessionCookieName, |
| 247 | SessionTimeout: toSeconds(action.sessionTimeout), |
| 248 | OnUnauthenticatedRequest: action.onUnauthenticatedRequest, |
| 249 | UseExistingClientSecret: action.useExistingClientSecret, |
| 250 | }, |
| 251 | }; |
| 252 | case "authenticateCognito": |
| 253 | return { |
| 254 | Type: "authenticate-cognito", |
| 255 | Order, |
| 256 | AuthenticateCognitoConfig: { |
| 257 | UserPoolArn: action.userPoolArn, |
| 258 | UserPoolClientId: action.userPoolClientId, |
| 259 | UserPoolDomain: action.userPoolDomain, |
| 260 | Scope: action.scope, |
| 261 | SessionCookieName: action.sessionCookieName, |
| 262 | SessionTimeout: toSeconds(action.sessionTimeout), |
| 263 | OnUnauthenticatedRequest: action.onUnauthenticatedRequest, |
| 264 | }, |
| 265 | }; |
| 266 | } |
| 267 | }); |
| 268 | |
| 269 | /** |
| 270 | * A condition under which a {@link ListenerRule} matches a request. Exactly one |
| 271 | * of the config fields should be set per condition; multiple conditions on a |
| 272 | * rule are AND-ed together, while values within a single condition are OR-ed. |
| 273 | */ |
| 274 | export interface ListenerRuleCondition { |
| 275 | /** Match on the `Host` header. Supports `*` and `?` wildcards. */ |
| 276 | hostHeader?: { values?: string[]; regexValues?: string[] }; |
| 277 | /** Match on the request path. Supports `*` and `?` wildcards. */ |
| 278 | pathPattern?: { values?: string[]; regexValues?: string[] }; |
| 279 | /** Match on a named HTTP header. */ |
| 280 | httpHeader?: { |
| 281 | name: string; |
| 282 | values?: string[]; |
| 283 | regexValues?: string[]; |
| 284 | }; |
| 285 | /** Match on query-string key/value pairs. Supports `*` and `?` wildcards. */ |
| 286 | queryString?: { values: { key?: string; value: string }[] }; |
| 287 | /** Match on the HTTP request method (GET, POST, ...). */ |
| 288 | httpRequestMethod?: { values: string[]; regexValues?: string[] }; |
| 289 | /** Match on the source IP CIDR. */ |
| 290 | sourceIp?: { values: string[] }; |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Serialize a list of {@link ListenerRuleCondition} into the wire |
| 295 | * `RuleCondition[]` shape AWS expects. |
| 296 | */ |
| 297 | export const serializeConditions = ( |
| 298 | conditions: ListenerRuleCondition[], |
| 299 | ): elbv2.RuleCondition[] => |
| 300 | conditions.flatMap((condition): elbv2.RuleCondition[] => { |
| 301 | const out: elbv2.RuleCondition[] = []; |
| 302 | if (condition.hostHeader) { |
| 303 | out.push({ |
| 304 | Field: "host-header", |
| 305 | HostHeaderConfig: { |
| 306 | Values: condition.hostHeader.values, |
| 307 | RegexValues: condition.hostHeader.regexValues, |
| 308 | }, |
| 309 | }); |
| 310 | } |
| 311 | if (condition.pathPattern) { |
| 312 | out.push({ |
| 313 | Field: "path-pattern", |
| 314 | PathPatternConfig: { |
| 315 | Values: condition.pathPattern.values, |
| 316 | RegexValues: condition.pathPattern.regexValues, |
| 317 | }, |
| 318 | }); |
| 319 | } |
| 320 | if (condition.httpHeader) { |
| 321 | out.push({ |
| 322 | Field: "http-header", |
| 323 | HttpHeaderConfig: { |
| 324 | HttpHeaderName: condition.httpHeader.name, |
| 325 | Values: condition.httpHeader.values, |
| 326 | RegexValues: condition.httpHeader.regexValues, |
| 327 | }, |
| 328 | }); |
| 329 | } |
| 330 | if (condition.queryString) { |
| 331 | out.push({ |
| 332 | Field: "query-string", |
| 333 | QueryStringConfig: { |
| 334 | Values: condition.queryString.values.map((v) => ({ |
| 335 | Key: v.key, |
| 336 | Value: v.value, |
| 337 | })), |
| 338 | }, |
| 339 | }); |
| 340 | } |
| 341 | if (condition.httpRequestMethod) { |
| 342 | out.push({ |
| 343 | Field: "http-request-method", |
| 344 | HttpRequestMethodConfig: { |
| 345 | Values: condition.httpRequestMethod.values, |
| 346 | }, |
| 347 | }); |
| 348 | } |
| 349 | if (condition.sourceIp) { |
| 350 | out.push({ |
| 351 | Field: "source-ip", |
| 352 | SourceIpConfig: { Values: condition.sourceIp.values }, |
| 353 | }); |
| 354 | } |
| 355 | return out; |
| 356 | }); |