diff --git a/deploy/cloudformation/edge-stack.yaml b/deploy/cloudformation/edge-stack.yaml new file mode 100644 index 0000000..2c97c90 --- /dev/null +++ b/deploy/cloudformation/edge-stack.yaml @@ -0,0 +1,156 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: > + Lowkey WebUI Cognito Auth — Lambda@Edge companion stack. + Deploys the Lambda@Edge function, its version, IAM role, and the two + Secrets Manager secrets (signing key + merged edge config) in us-east-1 + because Lambda@Edge sources MUST live in us-east-1 (AWS platform + requirement). The main Lowkey stack can deploy in any region; the + installer wires the two together via CFN parameters and cross-region + Secrets Manager writes. + +Parameters: + EnvironmentName: + Type: String + MinLength: 1 + Description: "Environment name — matches the main stack's EnvironmentName so secret names align." + + PackName: + Type: String + Default: kirocrew + Description: "Pack that owns this edge Lambda. Used only for tags." + + EdgeLambdaS3Bucket: + Type: String + MinLength: 1 + Description: "S3 bucket (in us-east-1) holding the Lambda@Edge deployment zip." + + EdgeLambdaS3Key: + Type: String + MinLength: 1 + Description: "S3 key of the Lambda@Edge deployment zip." + + EdgeLambdaCodeSha256: + Type: String + MinLength: 1 + Description: "Base64 SHA256 of the deployment zip. Forces a new AWS::Lambda::Version when code changes." + +Rules: + WebUIEdgeRequiresUsEast1: + Assertions: + - Assert: !Equals [!Ref 'AWS::Region', 'us-east-1'] + AssertDescription: "Lambda@Edge companion stack must be deployed in us-east-1." + +Resources: + # Raw HMAC signing key. CFN-managed via GenerateSecretString. Never written + # to by anything else — read once by the main-stack Custom Resource which + # then merges it into EdgeConfigSecret alongside the Cognito pool/client/domain. + WebUIEdgeSigningKeySecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Sub '/lowkey/${EnvironmentName}/webui-edge-signing-key' + Description: !Sub 'Raw HMAC signing key for KiroCrew WebUI Lambda@Edge (${EnvironmentName})' + GenerateSecretString: + SecretStringTemplate: '{}' + GenerateStringKey: 'key' + PasswordLength: 64 + ExcludePunctuation: true + Tags: + - Key: loki:managed + Value: 'true' + - Key: loki:pack + Value: !Ref PackName + - Key: loki:env + Value: !Ref EnvironmentName + + # Merged {poolId, clientId, cognitoDomain, signingKey} secret that the + # Lambda@Edge reads at cold start. Initial SecretString is a placeholder; + # the main-stack Custom Resource overwrites it once the Cognito pool exists. + WebUIEdgeConfigSecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Sub '/lowkey/${EnvironmentName}/webui-edge-config' + Description: !Sub 'Merged Cognito config for KiroCrew WebUI Lambda@Edge (${EnvironmentName})' + SecretString: '{"poolId":"pending","clientId":"pending","cognitoDomain":"pending","signingKey":"pending"}' + Tags: + - Key: loki:managed + Value: 'true' + - Key: loki:pack + Value: !Ref PackName + - Key: loki:env + Value: !Ref EnvironmentName + + WebUIEdgeLambdaRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${EnvironmentName}-webui-edge-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + - edgelambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: FetchEdgeConfig + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Ref WebUIEdgeConfigSecret + + WebUIEdgeLambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Sub '${EnvironmentName}-webui-edge-auth' + Runtime: nodejs22.x + Handler: index.handler + MemorySize: 128 + Timeout: 5 + Role: !GetAtt WebUIEdgeLambdaRole.Arn + Code: + S3Bucket: !Ref EdgeLambdaS3Bucket + S3Key: !Ref EdgeLambdaS3Key + + WebUIEdgeLambdaVersion: + Type: AWS::Lambda::Version + # Old versions cannot be deleted while CloudFront still references them + # (Lambda@Edge replicas take ~1hr to GC after CloudFront disassociates). + # Retain them on stack update/delete so a code refresh doesn't roll back + # the edge stack. Old versions are free — they accumulate harmlessly. + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + FunctionName: !Ref WebUIEdgeLambdaFunction + Description: !Sub 'KiroCrew WebUI Cognito auth (${EnvironmentName}) sha256=${EdgeLambdaCodeSha256}' + CodeSha256: !Ref EdgeLambdaCodeSha256 + +Outputs: + EdgeLambdaVersionArn: + Description: "Versioned ARN of the Lambda@Edge function. Pass this to the main stack's WebUIEdgeLambdaVersionArn parameter." + Value: !Ref WebUIEdgeLambdaVersion + + EdgeLambdaFunctionArn: + Description: "Unversioned ARN of the Lambda@Edge function." + Value: !GetAtt WebUIEdgeLambdaFunction.Arn + + EdgeConfigSecretName: + Description: "Deterministic name of the merged edge config secret. Custom Resource in the main stack uses this via a cross-region Secrets Manager client." + Value: !Sub '/lowkey/${EnvironmentName}/webui-edge-config' + + EdgeConfigSecretArn: + Description: "Full ARN of the edge config secret." + Value: !Ref WebUIEdgeConfigSecret + + SigningKeySecretName: + Description: "Deterministic name of the raw signing key secret." + Value: !Sub '/lowkey/${EnvironmentName}/webui-edge-signing-key' + + SigningKeySecretArn: + Description: "Full ARN of the signing key secret." + Value: !Ref WebUIEdgeSigningKeySecret diff --git a/deploy/cloudformation/template.yaml b/deploy/cloudformation/template.yaml index 1b3f166..0ff8566 100644 --- a/deploy/cloudformation/template.yaml +++ b/deploy/cloudformation/template.yaml @@ -372,6 +372,30 @@ Parameters: Description: "Email for the initial WebUI admin user. Required when EnableWebUIAuth is true." AllowedPattern: '^([^@]+@[^@]+\.[^@]+)?$' + EdgeLambdaVersionArn: + Type: String + Default: '' + Description: "Versioned ARN of the Lambda@Edge function (produced by the companion edge-stack in us-east-1). Required when EnableWebUIAuth is true." + + EdgeConfigSecretName: + Type: String + Default: '' + Description: "Name of the us-east-1 Secrets Manager secret holding the merged Cognito config for the Lambda@Edge. Required when EnableWebUIAuth is true." + + EdgeConfigSecretArn: + Type: String + Default: '' + Description: "Full ARN of the us-east-1 edge config secret. Required when EnableWebUIAuth is true." + + SigningKeySecretName: + Type: String + Default: '' + Description: "Name of the us-east-1 Secrets Manager secret holding the raw signing key. Required when EnableWebUIAuth is true." + + SigningKeySecretArn: + Type: String + Default: '' + Description: "Full ARN of the us-east-1 signing key secret. Required when EnableWebUIAuth is true." # ============================================================================ # RULES # ============================================================================ @@ -402,6 +426,20 @@ Rules: - Assert: !Not [!Equals [!Ref WebUIAdminEmail, '']] AssertDescription: "WebUIAdminEmail is required when EnableWebUIAuth is true." + WebUIEdgeRequiresParams: + RuleCondition: !Equals [!Ref EnableWebUIAuth, 'true'] + Assertions: + - Assert: !Not [!Equals [!Ref EdgeLambdaVersionArn, '']] + AssertDescription: "EdgeLambdaVersionArn is required when EnableWebUIAuth is true (installer deploys the companion edge stack in us-east-1 first and passes its outputs to this stack)." + - Assert: !Not [!Equals [!Ref EdgeConfigSecretName, '']] + AssertDescription: "EdgeConfigSecretName is required when EnableWebUIAuth is true (from the edge stack outputs)." + - Assert: !Not [!Equals [!Ref EdgeConfigSecretArn, '']] + AssertDescription: "EdgeConfigSecretArn is required when EnableWebUIAuth is true (from the edge stack outputs)." + - Assert: !Not [!Equals [!Ref SigningKeySecretName, '']] + AssertDescription: "SigningKeySecretName is required when EnableWebUIAuth is true (from the edge stack outputs)." + - Assert: !Not [!Equals [!Ref SigningKeySecretArn, '']] + AssertDescription: "SigningKeySecretArn is required when EnableWebUIAuth is true (from the edge stack outputs)." + # ============================================================================ # CONDITIONS # ============================================================================ @@ -627,6 +665,25 @@ Resources: Port: 80 Protocol: HTTP DefaultActions: + - Type: fixed-response + FixedResponseConfig: + ContentType: text/plain + MessageBody: Forbidden + StatusCode: '403' + + KiroCrewOriginVerifyListenerRule: + Type: AWS::ElasticLoadBalancingV2::ListenerRule + Condition: IsKiroCrew + Properties: + ListenerArn: !Ref KiroCrewHTTPListener + Priority: 1 + Conditions: + - Field: http-header + HttpHeaderConfig: + HttpHeaderName: x-origin-verify + Values: + - !Sub '{{resolve:secretsmanager:${KiroCrewOriginVerifySecret}}}' + Actions: - Type: forward TargetGroupArn: !Ref KiroCrewTargetGroup @@ -672,6 +729,12 @@ Resources: - DELETE CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad' # CachingDisabled OriginRequestPolicyId: '216adef6-5c7f-47e4-b989-5492eafa07d3' # AllViewer + LambdaFunctionAssociations: !If + - EnableWebUI + - - EventType: viewer-request + LambdaFunctionARN: !Ref EdgeLambdaVersionArn + IncludeBody: false + - !Ref 'AWS::NoValue' Origins: - Id: kirocrew-alb-origin DomainName: !GetAtt KiroCrewALB.DNSName @@ -1471,6 +1534,7 @@ Resources: - !Sub 'https://${KiroCrewDistribution.DomainName}/auth/callback' - 'http://localhost:5476/auth/callback' LogoutURLs: + - !Sub 'https://${KiroCrewDistribution.DomainName}/logout' - !Sub 'https://${KiroCrewDistribution.DomainName}/' - 'http://localhost:5476/' PreventUserExistenceErrors: ENABLED @@ -1526,7 +1590,13 @@ Resources: Action: - secretsmanager:PutSecretValue - secretsmanager:UpdateSecret - Resource: !Ref WebUIAdminSecret + Resource: + - !Ref WebUIAdminSecret + - !Ref EdgeConfigSecretArn + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Ref SigningKeySecretArn WebUIUserCreationFunction: Type: AWS::Lambda::Function @@ -1572,12 +1642,24 @@ Resources: secrets.SystemRandom().shuffle(pwd) return ''.join(pwd) - def write_secret(sm, secret_arn, email, password): + def write_admin_secret(sm, secret_arn, email, password): sm.put_secret_value( SecretId=secret_arn, SecretString=json.dumps({'email': email, 'password': password}) ) + def write_edge_config(sm, secret_arn, pool_id, client_id, cognito_domain, signing_key, cognito_region): + sm.put_secret_value( + SecretId=secret_arn, + SecretString=json.dumps({ + 'poolId': pool_id, + 'clientId': client_id, + 'cognitoDomain': cognito_domain, + 'signingKey': signing_key, + 'cognitoRegion': cognito_region, + }) + ) + def create_or_reset_user(cognito, pool_id, email, password): try: cognito.admin_create_user( @@ -1590,8 +1672,8 @@ Resources: MessageAction='SUPPRESS' ) except cognito.exceptions.UsernameExistsException: - # User exists (e.g. stack update or re-run); reset the password - # so the credentials in Secrets Manager remain valid. + # User exists (stack update or re-run); reset password so the credentials + # in Secrets Manager remain authoritative. pass cognito.admin_set_user_password( UserPoolId=pool_id, @@ -1611,48 +1693,69 @@ Resources: print(f'[WARN] Could not delete old user {email}: {e}') def handler(event, context): - # Log event WITHOUT ResourceProperties (which may contain email) + # Never log ResourceProperties (contains email + secret ARNs) print(f"[INFO] RequestType={event.get('RequestType')} LogicalId={event.get('LogicalResourceId')}") try: if event['RequestType'] == 'Delete': - # Secret and user pool are deleted by CFN — no manual cleanup needed send_response(event, context, 'SUCCESS', 'Delete is a no-op') return props = event.get('ResourceProperties', {}) old_props = event.get('OldResourceProperties', {}) pool_id = props.get('UserPoolId', '') + client_id = props.get('ClientId', '') + cognito_domain = props.get('CognitoDomain', '') email = props.get('AdminEmail', '') - secret_arn = os.environ.get('SECRET_ARN', '') + admin_secret_arn = props.get('AdminSecretArn', '') + edge_config_secret_name = props.get('EdgeConfigSecretName', '') + edge_config_secret_arn = props.get('EdgeConfigSecretArn', '') + signing_key_secret_name = props.get('SigningKeySecretName', '') + signing_key_secret_arn = props.get('SigningKeySecretArn', '') + edge_region = props.get('EdgeRegion', 'us-east-1') region = props.get('Region', os.environ.get('AWS_REGION', 'us-east-1')) - if not pool_id or not email or not secret_arn: - send_response(event, context, 'FAILED', - 'Missing UserPoolId, AdminEmail, or SECRET_ARN env') + missing = [k for k, v in { + 'UserPoolId': pool_id, + 'ClientId': client_id, + 'CognitoDomain': cognito_domain, + 'AdminEmail': email, + 'AdminSecretArn': admin_secret_arn, + 'EdgeConfigSecretName': edge_config_secret_name, + 'SigningKeySecretName': signing_key_secret_name, + }.items() if not v] + if missing: + send_response(event, context, 'FAILED', f'Missing props: {missing}') return cognito = boto3.client('cognito-idp', region_name=region) - sm = boto3.client('secretsmanager', region_name=region) + sm_local = boto3.client('secretsmanager', region_name=region) + sm_edge = boto3.client('secretsmanager', region_name=edge_region) - # On Update, if email changed, delete the old user first + # On Update, delete the old admin user if email changed if event['RequestType'] == 'Update': old_email = old_props.get('AdminEmail', '') if old_email and old_email != email: delete_user_safely(cognito, pool_id, old_email) + # 1) Provision the admin user in Cognito and store credentials + # in the local (main-stack region) admin secret. password = generate_password() create_or_reset_user(cognito, pool_id, email, password) - write_secret(sm, secret_arn, email, password) + write_admin_secret(sm_local, admin_secret_arn, email, password) - # Return only non-sensitive data; password is in Secrets Manager - send_response(event, context, 'SUCCESS', 'Admin user provisioned', - {'Email': email, 'SecretArn': secret_arn}) + # 2) Read the CFN-generated signing key from us-east-1 (edge stack) + # and write the merged edge config to us-east-1 (edge stack). + # Both live in us-east-1 because the Lambda@Edge reads them there. + sk_resp = sm_edge.get_secret_value(SecretId=signing_key_secret_name) + signing_key = json.loads(sk_resp['SecretString'])['key'] + write_edge_config(sm_edge, edge_config_secret_name, pool_id, client_id, cognito_domain, signing_key, region) + + send_response(event, context, 'SUCCESS', 'Admin user + edge config provisioned', + {'Email': email}) except Exception as e: - # Never log the password; only the exception class + short message err = f'{type(e).__name__}: {str(e)[:180]}' print(f'[ERROR] {err}') send_response(event, context, 'FAILED', err) - WebUIUserCreationResource: Type: Custom::WebUIUserCreation Condition: EnableWebUI @@ -1662,9 +1765,27 @@ Resources: Properties: ServiceToken: !GetAtt WebUIUserCreationFunction.Arn UserPoolId: !Ref WebUIUserPool + ClientId: !Ref WebUIUserPoolClient + CognitoDomain: !Sub '${WebUIUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com' AdminEmail: !Ref WebUIAdminEmail + AdminSecretArn: !Ref WebUIAdminSecret + EdgeConfigSecretName: !Ref EdgeConfigSecretName + EdgeConfigSecretArn: !Ref EdgeConfigSecretArn + SigningKeySecretName: !Ref SigningKeySecretName + SigningKeySecretArn: !Ref SigningKeySecretArn + EdgeRegion: 'us-east-1' Region: !Ref 'AWS::Region' + # -------------------------------------------------------------------------- + # WebUI Lambda@Edge (Cognito enforcement on CloudFront) + # -------------------------------------------------------------------------- + # Two secrets: + # - SigningKeySecret: raw HMAC key, CFN-managed via GenerateSecretString. + # Never rewritten by the Custom Resource. Read once by the Custom Resource + # to merge into EdgeConfigSecret. + # - EdgeConfigSecret: the merged config {poolId, clientId, cognitoDomain, + # signingKey} that the Lambda@Edge reads at cold start. Populated by the + # Custom Resource after Cognito resources are created. # SSM Session Manager Preferences (auto-login as ec2-user with welcome) # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- @@ -1883,3 +2004,13 @@ Outputs: Description: ARN of Secrets Manager secret holding initial WebUI admin credentials (email + password). Rotate/delete after first use. Value: !Ref WebUIAdminSecret + WebUIEdgeFunctionArn: + Condition: EnableWebUI + Description: Lambda@Edge function version ARN attached to the CloudFront distribution (viewer-request) + Value: !Ref EdgeLambdaVersionArn + + WebUIEdgeSigningKeySecretArn: + Condition: EnableWebUI + Description: ARN of Secrets Manager secret holding the Lambda@Edge nonce/state signing key + Value: !Ref SigningKeySecretArn + diff --git a/docs/design/kirocrew-webui-auth.md b/docs/design/kirocrew-webui-auth.md index c1049b8..9b65599 100644 --- a/docs/design/kirocrew-webui-auth.md +++ b/docs/design/kirocrew-webui-auth.md @@ -339,3 +339,292 @@ Uninstall flow: - [ ] KiroCrew gateway: implement JWT middleware (separate PR on KiroCrew repo) - [ ] Update uninstaller to handle Cognito cleanup - [ ] Add telemetry events: `install.webui_auth_configured` + +--- + +# Lambda@Edge Enforcement (v2) + +**Status:** Design phase. Ships in follow-up PR after PR #85. + +## Problem With v1 + +The v1 design (PR #82, #83) creates the Cognito pool, client, domain, and initial user, but **nothing actually enforces authentication on the CloudFront request path**. Requests flow CloudFront → ALB → EC2 → KiroCrew dashboard, which uses its own legacy `?token=` query-param scheme. The Cognito resources exist but are unused. + +## Solution: Lambda@Edge on the CloudFront Distribution + +Lambda@Edge (viewer-request trigger) intercepts every request to the CloudFront distribution, validates a Cognito session cookie, and redirects unauthenticated users to Cognito hosted UI. Uses the AWS-published `cognito-at-edge` library. + +## Architecture + +``` +Browser + │ + ▼ +CloudFront (KiroCrewDistribution) + │ ┌─────────────────────────────┐ + ├──│ Viewer-Request Lambda@Edge │ ← every request goes through here + │ │ (cognito-at-edge) │ + │ └─────────────────────────────┘ + │ │ + │ ├─ Has valid session cookie? ──► forward to ALB origin + │ │ + │ ├─ Path is /auth/callback? ──► exchange code for tokens, set cookie, redirect to / + │ │ + │ └─ No session? ──► 302 → Cognito hosted UI login + │ + ▼ +ALB → EC2 → KiroCrew dashboard +``` + +## Constraints (Lambda@Edge Specifics) + +- **Runtime**: Node.js 18.x (has AWS SDK v3 pre-installed, saves package size). +- **Region**: Function MUST live in `us-east-1` (CloudFront requirement). Executes replicated at every edge location. +- **No environment variables**. Configuration must be baked into code at build time OR fetched at cold start from Secrets Manager / SSM Parameter Store. +- **No VPC access.** Fine — Cognito is public API. +- **50 MB unzipped package limit.** `cognito-at-edge` + deps ≈ 2 MB — well under. +- **Cold-start budget**: <500 ms for viewer-request. Fetching Secrets Manager on cold start adds ~100-200 ms per edge region — acceptable. +- **Update propagation**: Publishing a new Lambda version replicates globally over several minutes. + +## Build & Deploy Flow + +``` +1. Installer (install.sh): + a. cd packs/kirocrew/webui-auth-edge/ + b. npm install --production + c. Substitute placeholders in index.js: + - __POOL_ID__ → resolved via CFN param + - __CLIENT_ID__ → resolved via CFN param + - __COGNITO_DOMAIN__→ resolved via CFN param + - __SECRET_ARN__ → deterministic name pattern + - __REGION__ → us-east-1 + d. zip -r edge-lambda-.zip . + e. aws s3 cp edge-lambda-.zip s3:///edge/edge-lambda-.zip + f. Pass S3 bucket + key as CFN parameters: + - EdgeLambdaS3Bucket + - EdgeLambdaS3Key + +2. CloudFormation stack create: + - WebUIEdgeSigningKeySecret (Secrets Manager, GenerateSecretString, 64-char hex) + - WebUIEdgeLambdaRole (trust: lambda.amazonaws.com + edgelambda.amazonaws.com) + - WebUIEdgeLambdaFunction (Code.S3Bucket/S3Key, Runtime nodejs18.x, us-east-1) + - WebUIEdgeLambdaVersion (needed for association) + - Update KiroCrewDistribution: add LambdaFunctionAssociations[viewer-request] + - Update WebUIUserPoolClient: callback URLs already correct (from v1) + +3. Cold start on first request: + - Lambda fetches signing key from Secrets Manager via IAM role + - Caches in module scope for subsequent invocations at same edge +``` + +## Signing Key Handling + +- Stored in `AWS::SecretsManager::Secret` with `GenerateSecretString` (64-char hex, alphanumeric). +- Secret ARN is deterministic: `arn:aws:secretsmanager:us-east-1::secret:/lowkey//webui-edge-signing-key`. Baked into Lambda code at build time. +- IAM policy on Lambda role grants `secretsmanager:GetSecretValue` on that specific ARN. +- Rotating the secret invalidates all sessions (all users get kicked out) — acceptable, forces re-auth. + +## Handler Code (skeleton) + +```javascript +// packs/kirocrew/webui-auth-edge/index.js +import { Authenticator } from 'cognito-at-edge'; +import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; + +const REGION = 'us-east-1'; +const POOL_ID = '__POOL_ID__'; +const CLIENT_ID = '__CLIENT_ID__'; +const COGNITO_DOMAIN = '__COGNITO_DOMAIN__'; +const SECRET_ARN = '__SECRET_ARN__'; + +let authenticatorPromise = null; + +async function getAuthenticator() { + if (authenticatorPromise) return authenticatorPromise; + authenticatorPromise = (async () => { + const sm = new SecretsManagerClient({ region: REGION }); + const resp = await sm.send(new GetSecretValueCommand({ SecretId: SECRET_ARN })); + const signingKey = JSON.parse(resp.SecretString).key; + return new Authenticator({ + region: REGION, + userPoolId: POOL_ID, + userPoolAppId: CLIENT_ID, + userPoolDomain: COGNITO_DOMAIN, + cookieExpirationDays: 1, + logLevel: 'warn', + // cognito-at-edge signs its nonce/state with a key derived from client-secret; + // since we use no-secret public clients, we pass a shared signing seed here: + cookieSettings: { idToken: null, accessToken: null, refreshToken: null }, + }); + })(); + return authenticatorPromise; +} + +export const handler = async (event) => { + const auth = await getAuthenticator(); + return auth.handle(event); +}; +``` + +## Cost Impact + +- Lambda@Edge: $0.60 / 1M requests + $0.00005001 / GB-sec. Typical dashboard use = <10k req/mo → <$0.01/mo. +- Secrets Manager: $0.40 / secret / mo + $0.05 / 10k API calls. One secret + <100 cold starts/mo → $0.40/mo. +- CloudWatch Logs (edge): pennies. +- **Total: ~$0.40-0.50/mo per deployment.** + +## Rollback + +If Lambda@Edge causes issues, remove the `LambdaFunctionAssociations` block from the CloudFront distribution — takes ~15 min to fully propagate. CloudFront falls back to unauthenticated forwarding (same as v1 today). + +## Deferred / Out-of-Scope + +- **Logout**: cognito-at-edge doesn't handle logout natively. User must clear cookies OR visit `https:///logout?client_id=X&logout_uri=Y`. Follow-up work: add `/logout` path handler in Lambda@Edge. +- **Refresh token flow**: cognito-at-edge auto-refreshes ID token using refresh token. Works out of the box. +- **CSRF**: cognito-at-edge handles state param + PKCE. Verified against upstream code. +- **Localhost/SSM port-forward access**: Lambda@Edge does not intercept direct EC2 access. If users port-forward `localhost:5476`, they hit the dashboard's own `?token` auth. Acceptable — SSM is already authenticated. + +## Implementation Checklist + +- [ ] `packs/kirocrew/webui-auth-edge/` directory with `package.json`, `index.js`, `build.sh` +- [ ] `install.sh`: npm install + zip + S3 upload before CFN deploy +- [ ] `install.sh`: two new CFN params `EdgeLambdaS3Bucket`, `EdgeLambdaS3Key` in `PARAM_CFN_NAMES` + `PARAM_VALUES` +- [ ] CFN: `WebUIEdgeSigningKeySecret`, `WebUIEdgeLambdaRole`, `WebUIEdgeLambdaFunction`, `WebUIEdgeLambdaVersion` (all `Condition: EnableWebUI`) +- [ ] CFN: `KiroCrewDistribution.DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations` = viewer-request → new function version +- [ ] CFN outputs: `WebUIEdgeFunctionArn`, `WebUIEdgeSigningKeySecretArn` +- [ ] Validate template, `bash -n install.sh`, deploy test stack + +--- + +# Split-Stack Architecture (v3) — Region-Agnostic Lambda@Edge + +**Status:** Implementation in progress on `feat/lambda-edge-cognito`. + +## Problem With v2 + +The v2 design locked the entire main stack to `us-east-1` via a CFN Rule, because Lambda@Edge functions must be sourced from us-east-1 (AWS platform requirement). This broke users deploying to `eu-west-1`, `ap-southeast-1`, etc. + +## Solution: Companion Edge Stack in us-east-1 + +Split the Lambda@Edge resources out of the main stack into a dedicated **companion stack** that always deploys in `us-east-1`, regardless of where the user chooses to deploy the main stack. + +## Architecture + +``` +USER'S CHOSEN REGION ($DEPLOY_REGION) us-east-1 (always) +───────────────────────────────────────── ──────────────────────────── +Main Stack (lowkey-stack) Edge Stack (lowkey-edge-stack) + VPC, EC2, ALB, Cognito WebUIEdgeLambdaFunction + CloudFront ──────────────────────────────────► WebUIEdgeLambdaVersion + WebUIUserPool / Client / Domain WebUIEdgeLambdaRole + WebUIAdminSecret WebUIEdgeSigningKeySecret + WebUIUserCreationFunction ───────────────────► WebUIEdgeConfigSecret + (written cross-region + after Cognito created) +``` + +### Deployment Flow (Installer) + +``` +1. User chooses region (e.g. eu-west-1) + enables WebUI auth +2. Installer builds + uploads edge Lambda zip to S3 in us-east-1 +3. Installer deploys edge-stack.yaml in us-east-1 + → Outputs: EdgeLambdaVersionArn, EdgeConfigSecretArn/Name, + SigningKeySecretArn/Name +4. Installer deploys template.yaml in $DEPLOY_REGION + → Passes edge-stack outputs as CFN parameters + → CloudFront references EdgeLambdaVersionArn (already in us-east-1) +5. Custom Resource (runs in $DEPLOY_REGION) writes to us-east-1 secrets + via cross-region boto3.client('secretsmanager', region_name='us-east-1') + → Writes admin creds to WebUIAdminSecret ($DEPLOY_REGION) + → Reads signing key from us-east-1 WebUIEdgeSigningKeySecret + → Writes merged config to us-east-1 WebUIEdgeConfigSecret +``` + +### Cross-Region Secret Writes + +The Custom Resource Lambda runs in `$DEPLOY_REGION` but must write to the us-east-1 config secret. It uses **two boto3 clients**: + +```python +sm_local = boto3.client('secretsmanager', region_name=region) # admin secret +sm_edge = boto3.client('secretsmanager', region_name='us-east-1') # edge config + signing key +``` + +IAM policy on `WebUIUserCreationRole` grants: +- `secretsmanager:PutSecretValue` on `WebUIAdminSecret` (regional ARN) +- `secretsmanager:PutSecretValue + GetSecretValue` on edge secrets (us-east-1 ARNs, cross-region OK via IAM) + +### Lambda@Edge Cold Start + +The Lambda@Edge function (in us-east-1) reads config from `WebUIEdgeConfigSecret` (also us-east-1) at cold start — no cross-region penalty. + +## Files + +| File | Purpose | +|------|---------| +| `deploy/cloudformation/edge-stack.yaml` | Companion stack, always deploys in us-east-1 | +| `deploy/cloudformation/template.yaml` | Main stack, deploys in `$DEPLOY_REGION` | +| `packs/kirocrew/webui-auth-edge/index.js` | Lambda@Edge handler | +| `packs/kirocrew/webui-auth-edge/build.sh` | Zip builder | + +## CFN Parameters — Main Stack (new) + +| Parameter | Source | Description | +|-----------|--------|-------------| +| `EdgeLambdaVersionArn` | edge-stack output | CloudFront associates this version | +| `EdgeConfigSecretName` | edge-stack output | Custom Resource uses for cross-region write | +| `EdgeConfigSecretArn` | edge-stack output | IAM Resource constraint | +| `SigningKeySecretName` | edge-stack output | Custom Resource reads signing key | +| `SigningKeySecretArn` | edge-stack output | IAM Resource constraint | + +## CFN Parameters — Edge Stack + +| Parameter | Source | Description | +|-----------|--------|-------------| +| `EnvironmentName` | installer | Matches main stack (aligns secret names) | +| `EdgeLambdaS3Bucket` | installer | us-east-1 S3 bucket with the zip | +| `EdgeLambdaS3Key` | installer | S3 key (content-addressed SHA hex) | +| `EdgeLambdaCodeSha256` | installer | Base64 SHA256 for Version CodeSha256 | + +## Installer Flow (2-phase) + +```bash +# Phase 1: Edge stack (us-east-1) +deploy_edge_stack() { + # Upload zip to us-east-1 bucket + aws s3 cp "$zip" s3://${edge_bucket}/${key} --region us-east-1 + # Deploy edge-stack.yaml in us-east-1 + aws cloudformation deploy --template-file edge-stack.yaml \ + --stack-name "${ENV_NAME}-edge-stack" --region us-east-1 \ + --parameter-overrides EnvironmentName=$ENV_NAME ... + # Capture outputs + EDGE_LAMBDA_VERSION_ARN=$(cfn output EdgeLambdaVersionArn) + EDGE_CONFIG_SECRET_NAME=$(cfn output EdgeConfigSecretName) + ... +} + +# Phase 2: Main stack ($DEPLOY_REGION) +deploy_cfn_stack() { + # Passes edge outputs as params: EdgeLambdaVersionArn, etc. + aws cloudformation deploy --template-file template.yaml \ + --stack-name "${ENV_NAME}-stack" --region $DEPLOY_REGION \ + --parameter-overrides EdgeLambdaVersionArn=$EDGE_LAMBDA_VERSION_ARN ... +} +``` + +## Uninstall + +Both stacks must be deleted. Edge stack deletion will wait up to ~1hr for Lambda@Edge replica GC before the function can be deleted (AWS behaviour). + +## Key Design Decisions + +- **S3 bucket for edge zip in us-east-1**: Lambda code S3 bucket must be in the same region as the function. The existing `$ENV_NAME-cfn-templates-$ACCOUNT_ID` bucket is in `$DEPLOY_REGION`; a separate `$ENV_NAME-edge-$ACCOUNT_ID` bucket is created in us-east-1. +- **Secret names are deterministic**: `/lowkey/$ENV_NAME/webui-edge-config` and `/lowkey/$ENV_NAME/webui-edge-signing-key`. This lets the Lambda@Edge code look them up by name without needing the full ARN in the zip. +- **Cross-region IAM**: IAM is global so the Custom Resource role can reference us-east-1 secret ARNs as Resource constraints even when running in eu-west-1. +- **Rollback isolation**: If main stack fails, edge stack stays. Re-running the installer after fixing the main-stack issue re-uses the existing edge stack (idempotent deploy). If edge stack fails, the main stack never starts. + +## Residual Risks + +- **Stale key cache**: Lambda@Edge caches config in module scope. Signing key rotation → warm replicas serve stale key until natural cold-start (hours). Mitigate: document that rotation requires a new edge stack deploy. +- **~1hr edge replica delete lag**: On stack teardown, `aws cloudformation delete-stack` on the edge stack will fail until Lambda@Edge replicas GC. Installer should surface this as an expected wait. +- **Brief 503 on first deploy**: Custom Resource runs after CloudFront deploys (no explicit DependsOn). First viewer request during that window → Lambda@Edge reads `"pending"` → returns 503 (graceful). Typically <30s window. diff --git a/install.sh b/install.sh index ddc56fc..a3b37fa 100755 --- a/install.sh +++ b/install.sh @@ -2155,6 +2155,117 @@ configure_webui_auth() { info "Initial password will be shown after stack deploys." } +# ============================================================================ +# WebUI Lambda@Edge zip build + S3 upload (KiroCrew, when auth enabled) +# ============================================================================ +# Builds the Cognito-at-Edge zip, uploads it to a dedicated us-east-1 bucket, +# and exports the edge artifact details for deploy_edge_stack. +build_and_upload_edge_lambda() { + [[ "${WEBUI_AUTH_ENABLED:-false}" == "true" ]] || return 0 + [[ "$PACK_NAME" == "kirocrew" ]] || return 0 + + # Preflight: required tools for the edge Lambda build path. + require_cmd node "node (22+) is required to build the Cognito Lambda@Edge zip. Install Node 22+ or disable WebUI auth." + require_cmd npm "npm is required to build the Cognito Lambda@Edge zip." + require_cmd zip "zip is required to package the Cognito Lambda@Edge deployment." + require_cmd openssl "openssl is required to compute the Lambda@Edge zip SHA256 for CFN." + + local build_script="${CLONE_DIR}/packs/kirocrew/webui-auth-edge/build.sh" + # In debug-in-repo mode CLONE_DIR points into the working repo; otherwise it + # points at the freshly-cloned copy. In either case the script must exist + # AFTER prepare_repo has run. This function is called from that point. + if [[ ! -x "$build_script" ]]; then + fail "Edge Lambda build script missing: $build_script" + fi + + step "WebUI Lambda@Edge" + info "Building Cognito Lambda@Edge zip..." + + # Deterministic config secret NAME (not ARN — Secrets Manager appends a random suffix + # to the ARN we can't know at build time, but names are stable). The Lambda@Edge + # bakes this name in and fetches the merged config at cold start. + local secret_name="/lowkey/${ENV_NAME}/webui-edge-config" + + local zip_path + zip_path=$(CONFIG_SECRET_NAME="$secret_name" "$build_script" 2>&1 | tail -1) \ + || fail "Edge Lambda build failed: $zip_path" + if [[ ! -f "$zip_path" ]]; then + fail "Edge Lambda build script did not produce a zip: $zip_path" + fi + + local bucket="${ENV_NAME}-edge-${ACCOUNT_ID}" + local key="edge/$(basename "$zip_path")" + + # Lambda@Edge requires its source bucket to be in us-east-1. Ensure the + # dedicated bucket exists (idempotent), independently of the main-region + # CloudFormation templates bucket. + if ! aws s3api head-bucket --bucket "$bucket" --region us-east-1 2>/dev/null; then + info "Creating edge Lambda bucket: $bucket" + aws s3api create-bucket --bucket "$bucket" --region us-east-1 \ + >/dev/null 2>&1 || fail "Failed to create bucket $bucket" + fi + aws s3api put-bucket-encryption --bucket "$bucket" \ + --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}' \ + --region us-east-1 >/dev/null 2>&1 || true + aws s3api put-bucket-versioning --bucket "$bucket" --versioning-configuration Status=Enabled \ + --region us-east-1 >/dev/null 2>&1 || fail "Failed to enable versioning on $bucket" + aws s3api put-public-access-block --bucket "$bucket" --region us-east-1 \ + --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true \ + >/dev/null 2>&1 || fail "Failed to block public access on $bucket" + + info "Uploading edge Lambda zip: s3://${bucket}/${key} ($(wc -c < "$zip_path") bytes)" + aws s3 cp "$zip_path" "s3://${bucket}/${key}" --region us-east-1 >/dev/null \ + || fail "Failed to upload edge Lambda zip" + + # Compute base64-encoded SHA256 of the zip. CFN needs this on the + # AWS::Lambda::Version resource so a new version is published whenever + # the code changes; without it, CloudFront keeps pointing at the old + # version even though $LATEST has new code. + local code_sha256 + code_sha256=$(openssl dgst -sha256 -binary "$zip_path" | openssl base64 -A) \ + || fail "Failed to compute SHA256 of edge Lambda zip" + + export EDGE_S3_BUCKET_US_EAST_1="$bucket" + export EDGE_S3_KEY="$key" + export EDGE_CODE_SHA256="$code_sha256" + ok "Edge Lambda uploaded" +} + +deploy_edge_stack() { + [[ "${WEBUI_AUTH_ENABLED:-false}" == "true" ]] || return 0 + [[ "$PACK_NAME" == "kirocrew" ]] || return 0 + + local edge_stack_name="${ENV_NAME}-edge-stack" + step "Deploy WebUI Lambda@Edge companion stack" + if ! aws cloudformation deploy \ + --template-file "${CLONE_DIR}/deploy/cloudformation/edge-stack.yaml" \ + --stack-name "$edge_stack_name" \ + --region us-east-1 \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides \ + "EnvironmentName=${ENV_NAME}" "PackName=${PACK_NAME}" \ + "EdgeLambdaS3Bucket=${EDGE_S3_BUCKET_US_EAST_1}" \ + "EdgeLambdaS3Key=${EDGE_S3_KEY}" "EdgeLambdaCodeSha256=${EDGE_CODE_SHA256}"; then + fail "Lambda@Edge companion stack deployment failed; main stack was not started" + fi + + local outputs + outputs=$(aws cloudformation describe-stacks --stack-name "$edge_stack_name" --region us-east-1 \ + --query 'Stacks[0].Outputs' --output json) \ + || fail "Could not read outputs from Lambda@Edge companion stack" + EDGE_LAMBDA_VERSION_ARN=$(printf '%s' "$outputs" | jq -r '.[] | select(.OutputKey=="EdgeLambdaVersionArn") | .OutputValue') + EDGE_CONFIG_SECRET_NAME=$(printf '%s' "$outputs" | jq -r '.[] | select(.OutputKey=="EdgeConfigSecretName") | .OutputValue') + EDGE_CONFIG_SECRET_ARN=$(printf '%s' "$outputs" | jq -r '.[] | select(.OutputKey=="EdgeConfigSecretArn") | .OutputValue') + SIGNING_KEY_SECRET_NAME=$(printf '%s' "$outputs" | jq -r '.[] | select(.OutputKey=="SigningKeySecretName") | .OutputValue') + SIGNING_KEY_SECRET_ARN=$(printf '%s' "$outputs" | jq -r '.[] | select(.OutputKey=="SigningKeySecretArn") | .OutputValue') + export EDGE_LAMBDA_VERSION_ARN EDGE_CONFIG_SECRET_NAME EDGE_CONFIG_SECRET_ARN + export SIGNING_KEY_SECRET_NAME SIGNING_KEY_SECRET_ARN + [[ -n "$EDGE_LAMBDA_VERSION_ARN" && "$EDGE_LAMBDA_VERSION_ARN" != null ]] \ + || fail "Lambda@Edge companion stack returned no version ARN" + ok "Lambda@Edge companion stack deployed" +} + + collect_config() { step "Configuration" @@ -2288,7 +2399,7 @@ collect_security_config() { # Parameter source-of-truth: single mapping for CFN Console and CFN CLI # ============================================================================ # ⚠ KEEP THESE TWO ARRAYS IN SYNC — same order, same count -PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId ExistingSubnetId2 RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser Primary DailyDriver CodexModel EnableWebUIAuth WebUIAdminEmail) +PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId ExistingSubnetId2 RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser Primary DailyDriver CodexModel EnableWebUIAuth WebUIAdminEmail EdgeLambdaVersionArn EdgeConfigSecretName EdgeConfigSecretArn SigningKeySecretName SigningKeySecretArn) PARAM_VALUES=() # populated by build_deploy_params() # Per-pack default model (passed to CFN DefaultModel / bootstrap.sh --model). @@ -2350,6 +2461,11 @@ build_deploy_params() { "${TROIKA_CODEX_MODEL:-openai.gpt-5.5}" "${WEBUI_AUTH_ENABLED:-false}" "${WEBUI_ADMIN_EMAIL:-}" + "${EDGE_LAMBDA_VERSION_ARN:-}" + "${EDGE_CONFIG_SECRET_NAME:-}" + "${EDGE_CONFIG_SECRET_ARN:-}" + "${SIGNING_KEY_SECRET_NAME:-}" + "${SIGNING_KEY_SECRET_ARN:-}" ) # Validate parallel arrays are in sync [[ ${#PARAM_CFN_NAMES[@]} -eq ${#PARAM_VALUES[@]} ]] \ @@ -2377,6 +2493,16 @@ format_cfn_cli_params() { echo "$params" } +# Format params for CFN deploy --parameter-overrides (Key=Value) +format_cfn_deploy_params() { + local params="" + for i in "${!PARAM_CFN_NAMES[@]}"; do + [[ -n "$params" ]] && params+=" " + params+="${PARAM_CFN_NAMES[$i]}=${PARAM_VALUES[$i]}" + done + echo "$params" +} + show_summary() { step "Review & confirm" @@ -2567,23 +2693,21 @@ deploy_cfn_stack() { aws s3 cp "$template" "s3://${bucket}/lowkey/template.yaml" --region "$DEPLOY_REGION" >/dev/null \ || fail "Failed to upload template to S3" - # Pre-signed URL (bucket blocks public access) - local s3_url - s3_url=$(aws s3 presign "s3://${bucket}/lowkey/template.yaml" \ - --expires-in 3600 --region "$DEPLOY_REGION") \ - || fail "Could not generate pre-signed URL for template" - + # aws cloudformation deploy is idempotent: it creates a missing stack and + # updates an existing one. --no-fail-on-empty-changeset makes reruns safe. # shellcheck disable=SC2046 - aws cloudformation create-stack \ + aws cloudformation deploy \ --stack-name "$STACK_NAME" \ - --template-url "$s3_url" \ + --template-file "$template" \ + --s3-bucket "$bucket" \ + --s3-prefix lowkey/deploy \ --region "$DEPLOY_REGION" \ --capabilities $capabilities \ - --parameters $(format_cfn_cli_params) \ - --output text --query 'StackId' + --parameter-overrides $(format_cfn_deploy_params) \ + --no-fail-on-empty-changeset \ + || fail "CloudFormation deployment failed" - info "Stack creating... this takes ~8-10 minutes" - wait_for_cfn_stack + info "Stack deployment complete" INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$DEPLOY_REGION" \ --query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' --output text) @@ -3450,15 +3574,16 @@ main() { # Console deploy exits early (no clone, no bootstrap wait) if [[ "$DEPLOY_METHOD" == "$DEPLOY_CFN_CONSOLE" ]]; then + # P1 #5: Console mode can't build+upload the edge Lambda zip (no local + # clone, no shell access post-flow). WebUI auth requires CLI deploy. + if [[ "${WEBUI_AUTH_ENABLED:-false}" == "true" ]]; then + fail "WebUI authentication is not supported with the CloudFormation Console deploy method. Re-run and choose 'CloudFormation CLI' when prompted, or disable WebUI auth." + fi TOTAL_STEPS=5 _TELEM_CURRENT_STEP="deploy_console" _telem_deploy_started 2>/dev/null || true step "Deploy (Console)" deploy_console - if [[ "${WEBUI_AUTH_ENABLED:-false}" == "true" ]]; then - info "After deploying the stack, retrieve the initial admin password from stack outputs:" - info " aws cloudformation describe-stacks --stack-name --region ${DEPLOY_REGION} --query 'Stacks[0].Outputs' --output table" - fi _telem_install_completed 2>/dev/null || true exit 0 fi @@ -3470,6 +3595,14 @@ main() { prepare_repo echo "" + if [[ "${WEBUI_AUTH_ENABLED:-false}" == "true" && "$PACK_NAME" == "kirocrew" ]]; then + build_and_upload_edge_lambda + deploy_edge_stack + # Edge outputs are required by the main stack parameters; refresh the + # parallel parameter values after the companion stack completes. + build_deploy_params + fi + case "$DEPLOY_METHOD" in "$DEPLOY_CFN_CLI") info "Deploying with CloudFormation..." deploy_cfn_stack "deploy/cloudformation/template.yaml" "CAPABILITY_NAMED_IAM" ;; @@ -3510,6 +3643,10 @@ main() { " Client: ${client_id}" \ " Domain: ${domain}" \ "" \ + " Edge Lambda: ${EDGE_LAMBDA_VERSION_ARN}" \ + " Edge Region: us-east-1" \ + " Edge Stack: ${ENV_NAME}-edge-stack" \ + "" \ " Secret: ${secret_arn}" || true echo "" fi diff --git a/packs/kirocrew/webui-auth-edge/.npmrc b/packs/kirocrew/webui-auth-edge/.npmrc new file mode 100644 index 0000000..7253a5c --- /dev/null +++ b/packs/kirocrew/webui-auth-edge/.npmrc @@ -0,0 +1 @@ +min-release-age=7 diff --git a/packs/kirocrew/webui-auth-edge/build.sh b/packs/kirocrew/webui-auth-edge/build.sh new file mode 100755 index 0000000..32b6036 --- /dev/null +++ b/packs/kirocrew/webui-auth-edge/build.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Build the KiroCrew WebUI Cognito Lambda@Edge zip. +# +# Substitutes CONFIG_SECRET_NAME placeholder in index.js, runs npm install +# --production, and produces edge-lambda-.zip in $OUT_DIR (default: script dir). +# +# The Lambda code fetches all config (pool ID, client ID, domain, signing key) +# from Secrets Manager at cold start using the config secret name baked in +# here. This lets us build the zip BEFORE CFN creates the Cognito pool. +# +# Required env vars: +# CONFIG_SECRET_NAME — Secrets Manager name (not ARN) for the edge config secret +# e.g. /lowkey//webui-edge-config +# +# Optional: +# OUT_DIR — Where to place the zip (default: script dir) +# NODE_BIN — Node binary path (default: node from PATH) +# +# Exit codes: 0 success, non-zero failure with message on stderr. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${OUT_DIR:-$SCRIPT_DIR}" +NODE_BIN="${NODE_BIN:-node}" + +if [[ -z "${CONFIG_SECRET_NAME:-}" ]]; then + echo "build.sh: missing required env var: CONFIG_SECRET_NAME" >&2 + exit 2 +fi + +if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + echo "build.sh: node binary not found ($NODE_BIN)" >&2 + exit 4 +fi + +NODE_MAJOR=$("$NODE_BIN" -e 'console.log(process.versions.node.split(".")[0])') +if [[ "$NODE_MAJOR" -lt 22 ]]; then + echo "build.sh: node 22+ required, got $NODE_MAJOR" >&2 + exit 5 +fi + +BUILD_DIR=$(mktemp -d) +trap 'rm -rf "$BUILD_DIR"' EXIT + +cp "$SCRIPT_DIR/index.js" "$BUILD_DIR/index.js" +cp "$SCRIPT_DIR/package.json" "$BUILD_DIR/package.json" +cp "$SCRIPT_DIR/package-lock.json" "$BUILD_DIR/package-lock.json" +cp "$SCRIPT_DIR/.npmrc" "$BUILD_DIR/.npmrc" + +# Substitute only CONFIG_SECRET_NAME placeholder +sed -i -e "s|__CONFIG_SECRET_NAME__|${CONFIG_SECRET_NAME}|g" "$BUILD_DIR/index.js" + +if grep -q '__[A-Z_]*__' "$BUILD_DIR/index.js"; then + echo "build.sh: unresolved placeholders in index.js:" >&2 + grep '__[A-Z_]*__' "$BUILD_DIR/index.js" >&2 + exit 6 +fi + +cd "$BUILD_DIR" +npm ci --omit=dev --no-audit --no-fund --loglevel=error >&2 >&2 + +# Content-addressed zip name: hash the actual ZIP BYTES (not source files) so +# the S3 key stays in lockstep with CodeSha256 in CFN. Round-3 fix (P1 #2): +# hashing source only meant node_modules variance produced different +# CodeSha256 with same S3 key -> CFN Function unchanged but Version.CodeSha256 +# mismatch -> stack update fails. +# +# Portable SHA256: prefer openssl (universal on macOS + Linux); fall back to +# sha256sum (Linux) then shasum (macOS builtin). Round-3 fix (P2 #2). +sha256_hex() { + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -hex "$1" | awk '{print $NF}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + echo "build.sh: no sha256 tool available (need openssl, sha256sum, or shasum)" >&2 + return 1 + fi +} + +# Build the zip first, then hash it. Use a stable temp name during build. +TMP_ZIP="${OUT_DIR}/.edge-lambda-tmp-$$.zip" +trap 'rm -f "$TMP_ZIP"; rm -rf "$BUILD_DIR"' EXIT +rm -f "$TMP_ZIP" +zip -r -q -X "$TMP_ZIP" index.js package.json package-lock.json node_modules >&2 + +ZIP_SHA=$(sha256_hex "$TMP_ZIP") || exit 7 +SHA_SHORT="${ZIP_SHA:0:16}" +ZIP_NAME="edge-lambda-${SHA_SHORT}.zip" +ZIP_PATH="${OUT_DIR}/${ZIP_NAME}" + +mv "$TMP_ZIP" "$ZIP_PATH" + +echo "$ZIP_PATH" diff --git a/packs/kirocrew/webui-auth-edge/index.js b/packs/kirocrew/webui-auth-edge/index.js new file mode 100644 index 0000000..a94f82c --- /dev/null +++ b/packs/kirocrew/webui-auth-edge/index.js @@ -0,0 +1,109 @@ +/** + * KiroCrew WebUI Cognito Auth — Lambda@Edge (viewer-request trigger). + * + * Validates a Cognito session cookie set by cognito-at-edge. Unauthenticated + * requests are redirected to the Cognito hosted UI. Requests to /auth/callback + * exchange the auth code for tokens and set the session cookie. + * + * Lambda@Edge constraints: + * - No environment variables (config baked in below OR fetched from Secrets Manager) + * - Node.js 22.x runtime + * - Must be deployed in us-east-1 (enforced by CFN Rule WebUIEdgeRequiresUsEast1) + * + * Only CONFIG_SECRET_NAME is substituted at build time. All other config + * (pool ID, client ID, domain, signing key) is fetched from Secrets Manager + * on cold start, cached in module scope for warm invocations. + * + * This lets us zip the Lambda code BEFORE CFN creates the Cognito pool — + * the Lambda only needs the secret's name, which is a deterministic function + * of the stack's environment name. + */ + +const { Authenticator } = require('cognito-at-edge'); +const { + SecretsManagerClient, + GetSecretValueCommand, +} = require('@aws-sdk/client-secrets-manager'); + +const CONFIG_SECRET_NAME = '__CONFIG_SECRET_NAME__'; +const SECRETS_REGION = 'us-east-1'; + +const AUTHENTICATOR_CACHE_TTL_MS = 15 * 60 * 1000; +let authenticatorPromise = null; +let authenticatorCacheTimestamp = 0; + +async function loadConfig() { + const sm = new SecretsManagerClient({ region: SECRETS_REGION }); + const resp = await sm.send(new GetSecretValueCommand({ SecretId: CONFIG_SECRET_NAME })); + const parsed = JSON.parse(resp.SecretString); + const required = ['poolId', 'clientId', 'cognitoDomain', 'signingKey', 'cognitoRegion']; + const missing = required.filter((k) => !parsed[k] || typeof parsed[k] !== 'string' || parsed[k] === 'pending'); + if (missing.length > 0) { + throw new Error(`Edge config secret missing or pending fields: ${missing.join(', ')}`); + } + return parsed; +} + +async function getAuthenticator() { + const now = Date.now(); + if (authenticatorPromise && now - authenticatorCacheTimestamp <= AUTHENTICATOR_CACHE_TTL_MS) { + return authenticatorPromise; + } + authenticatorPromise = null; + authenticatorCacheTimestamp = now; + authenticatorPromise = (async () => { + const cfg = await loadConfig(); + return new Authenticator({ + // cognito-at-edge uses this region for JWKS + token validation and + // Cognito API calls. It must match the region the user pool lives in + // (which is the main stack's DEPLOY_REGION, NOT us-east-1 where the + // Lambda@Edge itself is hosted). + region: cfg.cognitoRegion, + userPoolId: cfg.poolId, + userPoolAppId: cfg.clientId, + userPoolDomain: cfg.cognitoDomain, + // Must match a CallbackURL registered on the Cognito user pool client + // in the main-stack template (`/auth/callback`). Without this, + // cognito-at-edge defaults to `/parseauth`, which Cognito rejects + // because it's not in the client's CallbackURLs allowlist. + parseAuthPath: '/auth/callback', + cookieExpirationDays: 1, + cookiePath: '/', + httpOnly: true, + sameSite: 'Lax', + disableCookieDomain: true, + logoutConfiguration: { + logoutUri: '/logout', + logoutRedirectUri: '/', + }, + logLevel: 'warn', + csrfProtection: { + nonceSigningSecret: cfg.signingKey, + }, + }); + })().catch((err) => { + authenticatorPromise = null; + authenticatorCacheTimestamp = 0; + throw err; + }); + return authenticatorPromise; +} + +exports.handler = async (event) => { + try { + const auth = await getAuthenticator(); + return auth.handle(event); + } catch (err) { + console.error('[edge-auth] handler error:', err && err.message); + // Fail closed — do NOT forward an unauthenticated request + return { + status: '503', + statusDescription: 'Service Unavailable', + headers: { + 'content-type': [{ key: 'Content-Type', value: 'text/plain' }], + 'cache-control': [{ key: 'Cache-Control', value: 'no-store' }], + }, + body: 'Auth service temporarily unavailable. Retry in a moment.', + }; + } +}; diff --git a/packs/kirocrew/webui-auth-edge/package-lock.json b/packs/kirocrew/webui-auth-edge/package-lock.json new file mode 100644 index 0000000..117e26d --- /dev/null +++ b/packs/kirocrew/webui-auth-edge/package-lock.json @@ -0,0 +1,500 @@ +{ + "name": "kirocrew-webui-auth-edge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kirocrew-webui-auth-edge", + "version": "1.0.0", + "dependencies": { + "cognito-at-edge": "^1.5.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-jwt-verify": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/aws-jwt-verify/-/aws-jwt-verify-2.1.3.tgz", + "integrity": "sha512-XAlt1IaQg9SRpuKPAhW1I1/E9Q63bPI/O+W5dcGniDwTJSbAUVZsH80XxeuADBCD2eIWEUlKOFfLmzhXZqt9tA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cognito-at-edge": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/cognito-at-edge/-/cognito-at-edge-1.5.4.tgz", + "integrity": "sha512-v1K7xkb2BxMk/fwnYVpPTUAqt8BSKfiUpUNxjc2HHFVLAdTHGGkYoEHL1x0QeJlViLAekg1ruHiV4Q1cMasqjg==", + "license": "Apache-2.0", + "dependencies": { + "aws-jwt-verify": "^2.1.1", + "axios": "^1.6.5", + "pino": "^9.12.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + } + } +} diff --git a/packs/kirocrew/webui-auth-edge/package.json b/packs/kirocrew/webui-auth-edge/package.json new file mode 100644 index 0000000..1ede988 --- /dev/null +++ b/packs/kirocrew/webui-auth-edge/package.json @@ -0,0 +1,13 @@ +{ + "name": "kirocrew-webui-auth-edge", + "version": "1.0.0", + "description": "Lambda@Edge Cognito enforcement for KiroCrew CloudFront distribution", + "main": "index.js", + "type": "commonjs", + "dependencies": { + "cognito-at-edge": "^1.5.2" + }, + "engines": { + "node": ">=22" + } +}