Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,25 +157,34 @@ def cfg(context_key: str, env_key: str, default: str) -> str:
"issuer_url": cfg("idp_issuer_url", "IDP_ISSUER_URL", ""),
}

# OAuth provider credentials
google_client_id = app.node.try_get_context("google_client_id") or os.environ.get(
"GOOGLE_CLIENT_ID", ""
# OAuth provider credentials (3LO). Secrets travel as Secrets Manager secret
# NAMES only — a plaintext *_client_secret context key or env var is rejected,
# because context values land in `ps` output, cdk.context.json, and (previously)
# verbatim in the synthesized template. deploy.sh upserts the secret and passes
# the name; see the identity stack for the dynamic-reference rendering.
for _vendor in ("google", "github", "notion"):
if app.node.try_get_context(f"{_vendor}_client_secret") or os.environ.get(
f"{_vendor.upper()}_CLIENT_SECRET", ""
):
raise ValueError(
f"Plaintext '{_vendor}_client_secret' / {_vendor.upper()}_CLIENT_SECRET is "
f"no longer supported — store it in Secrets Manager and pass "
f"'{_vendor}_client_secret_name' instead (scripts/deploy.sh does this "
"automatically when the secret is in the environment)."
)

google_client_id = cfg("google_client_id", "GOOGLE_CLIENT_ID", "")
google_client_secret_name = cfg(
"google_client_secret_name", "GOOGLE_CLIENT_SECRET_NAME", ""
)
google_client_secret = app.node.try_get_context(
"google_client_secret"
) or os.environ.get("GOOGLE_CLIENT_SECRET", "")
github_client_id = app.node.try_get_context("github_client_id") or os.environ.get(
"GITHUB_CLIENT_ID", ""
github_client_id = cfg("github_client_id", "GITHUB_CLIENT_ID", "")
github_client_secret_name = cfg(
"github_client_secret_name", "GITHUB_CLIENT_SECRET_NAME", ""
)
github_client_secret = app.node.try_get_context(
"github_client_secret"
) or os.environ.get("GITHUB_CLIENT_SECRET", "")
notion_client_id = app.node.try_get_context("notion_client_id") or os.environ.get(
"NOTION_CLIENT_ID", ""
notion_client_id = cfg("notion_client_id", "NOTION_CLIENT_ID", "")
notion_client_secret_name = cfg(
"notion_client_secret_name", "NOTION_CLIENT_SECRET_NAME", ""
)
notion_client_secret = app.node.try_get_context(
"notion_client_secret"
) or os.environ.get("NOTION_CLIENT_SECRET", "")

# ── Global Tags ──
cdk.Tags.of(app).add("Project", project)
Expand Down Expand Up @@ -279,11 +288,11 @@ def cfg(context_key: str, env_key: str, default: str) -> str:
gateway_m2m_client_secret=m2m_client_secret,
cognito_discovery_url=discovery_url,
google_client_id=google_client_id,
google_client_secret=google_client_secret,
google_client_secret_name=google_client_secret_name,
github_client_id=github_client_id,
github_client_secret=github_client_secret,
github_client_secret_name=github_client_secret_name,
notion_client_id=notion_client_id,
notion_client_secret=notion_client_secret,
notion_client_secret_name=notion_client_secret_name,
env=cdk_env,
)
if auth_stack:
Expand Down
30 changes: 29 additions & 1 deletion scripts/check-deploy-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ rm -f "$PLATFORM_CONFIG"
# A trailing newline (pasted, or piped from `az ... -o tsv`) is stored verbatim,
# Cognito forwards it to the IdP token endpoint, and the exchange fails with
# invalid_client mentioning nothing about whitespace.
eval "$(sed -n '/^upsert_idp_secret()/,/^}/p' "$SCRIPT_DIR/deploy.sh")"
eval "$(sed -n '/^upsert_oauth_secret()/,/^}/p; /^upsert_idp_secret()/,/^}/p; /^upsert_3lo_secrets()/,/^}/p' "$SCRIPT_DIR/deploy.sh")"
# The function unsets the plaintext when it is done (deliberate hygiene), so
# assert on what it PASSED to the CLI rather than on the variable afterwards.
# SC2034/SC2329: PREFIX, AWS_REGION and IDP_CLIENT_SECRET are read by the
Expand Down Expand Up @@ -242,4 +242,32 @@ grep -q -- "put-secret-value --secret-id my-corp/entra-secret" "$TMP/aws.args" \
unset -f aws prompt_idp
echo "PASS: a configured IdP secret name is reused, not duplicated"

# (m) 3LO client secrets follow the same road: trimmed, stored under the
# prefixed name (or a configured one), plaintext unset afterwards. These used
# to be rendered verbatim into the synthesized template via cdk context.
# shellcheck disable=SC2329
aws() { printf '%s\n' "$*" >> "$TMP/aws.args"; return 0; }
: > "$TMP/aws.args"
# shellcheck disable=SC2034 # read via indirection in the eval'd functions
GOOGLE_CLIENT_SECRET=$'g-sekret\n'
# shellcheck disable=SC2034
GITHUB_CLIENT_SECRET=" gh-sekret "
# shellcheck disable=SC2034
NOTION_CLIENT_SECRET_NAME="my-corp/notion" # bring-your-own name
# shellcheck disable=SC2034
NOTION_CLIENT_SECRET="n-sekret"
upsert_3lo_secrets >/dev/null 2>&1 || true
grep -q -- "--secret-string g-sekret " "$TMP/aws.args" \
|| fail "google secret newline not stripped: $(cat "$TMP/aws.args")"
grep -q -- "--secret-string gh-sekret " "$TMP/aws.args" \
|| fail "github secret padding not stripped: $(cat "$TMP/aws.args")"
grep -q -- "--secret-id my-corp/notion" "$TMP/aws.args" \
|| fail "notion bring-your-own name ignored: $(cat "$TMP/aws.args")"
[ "$GOOGLE_CLIENT_SECRET_NAME" = "check-prefix-google-oauth-secret" ] \
|| fail "google secret name not defaulted: ${GOOGLE_CLIENT_SECRET_NAME:-unset}"
[ -z "${GOOGLE_CLIENT_SECRET:-}${GITHUB_CLIENT_SECRET:-}${NOTION_CLIENT_SECRET:-}" ] \
|| fail "a 3LO plaintext survived the upsert"
unset -f aws
echo "PASS: 3LO client secrets are trimmed, named, and never persisted"

echo "OK: all deploy-config checks passed"
89 changes: 65 additions & 24 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -468,43 +468,72 @@ prompt_api_keys() {
# template). Instead we upsert it into Secrets Manager and pass only the
# secret NAME; the auth stack resolves the value at deploy time via a
# {{resolve:secretsmanager:...}} CloudFormation dynamic reference.
upsert_idp_secret() {
if [ -z "${IDP_CLIENT_SECRET:-}" ]; then return 0; fi
#
# upsert_oauth_secret VALUE_VAR NAME_VAR DEFAULT_NAME LABEL
# VALUE_VAR name of the variable holding the plaintext (unset afterwards)
# NAME_VAR name of the variable holding/receiving the secret's name
# DEFAULT_NAME secret name used when the operator did not configure one
# LABEL human label for log messages
upsert_oauth_secret() {
local value_var="$1" name_var="$2" default_name="$3" label="$4"
local value="${!value_var:-}"
if [ -z "$value" ]; then return 0; fi
# Strip surrounding whitespace. A secret pasted from a console, or piped in
# from `az ad app credential reset -o tsv`, arrives with a trailing newline;
# Cognito forwards it verbatim to the IdP's token endpoint and the exchange
# fails with invalid_client, naming nothing about whitespace. Cost an hour
# to find live — see docs/ENTERPRISE_IDP.md.
IDP_CLIENT_SECRET="$(printf '%s' "$IDP_CLIENT_SECRET" | tr -d '\n\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -z "$IDP_CLIENT_SECRET" ]; then
log_error "IDP_CLIENT_SECRET is only whitespace — nothing to store."
# from a CLI (`az ... -o tsv`), arrives with a trailing newline; it is
# stored and forwarded verbatim, and the provider's token endpoint rejects
# the exchange with invalid_client, naming nothing about whitespace. Cost
# an hour to find live — see docs/ENTERPRISE_IDP.md.
value="$(printf '%s' "$value" | tr -d '\n\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -z "$value" ]; then
log_error "$value_var is only whitespace — nothing to store."
exit 1
fi
# Write to the operator's secret when they named one (platform.yaml / env),
# otherwise to our own. Rotating into a bring-your-own secret must not
# silently fork a second copy under the prefixed name.
IDP_CLIENT_SECRET_NAME="${IDP_CLIENT_SECRET_NAME:-${PREFIX}-idp-client-secret}"
if aws secretsmanager describe-secret --secret-id "$IDP_CLIENT_SECRET_NAME" \
local secret_name="${!name_var:-$default_name}"
# Exported, not just assigned: app.py must see the name even on CDK calls
# that carry no context args (the bootstrap probe synthesizes the app too).
printf -v "$name_var" '%s' "$secret_name"
export "${name_var?}"
if aws secretsmanager describe-secret --secret-id "$secret_name" \
--region "$AWS_REGION" &>/dev/null; then
# Secret already exists — update the value (IdP secrets rotate).
if ! aws secretsmanager put-secret-value --secret-id "$IDP_CLIENT_SECRET_NAME" \
--secret-string "$IDP_CLIENT_SECRET" --region "$AWS_REGION" &>/dev/null; then
log_error "Failed to update Secrets Manager secret '$IDP_CLIENT_SECRET_NAME'."
# Secret already exists — update the value (client secrets rotate).
if ! aws secretsmanager put-secret-value --secret-id "$secret_name" \
--secret-string "$value" --region "$AWS_REGION" &>/dev/null; then
log_error "Failed to update Secrets Manager secret '$secret_name'."
log_error "Check IAM permissions for secretsmanager:PutSecretValue and retry."
exit 1
fi
log_info "✓ IdP client secret: updated in Secrets Manager ($IDP_CLIENT_SECRET_NAME)"
log_info "✓ $label secret: updated in Secrets Manager ($secret_name)"
else
if ! aws secretsmanager create-secret --name "$IDP_CLIENT_SECRET_NAME" \
--secret-string "$IDP_CLIENT_SECRET" --region "$AWS_REGION" &>/dev/null; then
log_error "Failed to create Secrets Manager secret '$IDP_CLIENT_SECRET_NAME'."
if ! aws secretsmanager create-secret --name "$secret_name" \
--secret-string "$value" --region "$AWS_REGION" &>/dev/null; then
log_error "Failed to create Secrets Manager secret '$secret_name'."
log_error "Check IAM permissions for secretsmanager:CreateSecret and retry."
exit 1
fi
log_info "✓ IdP client secret: stored in Secrets Manager ($IDP_CLIENT_SECRET_NAME)"
log_info "✓ $label secret: stored in Secrets Manager ($secret_name)"
fi
# Plaintext is no longer needed — only the secret name is passed to CDK.
unset IDP_CLIENT_SECRET
unset "$value_var"
}

upsert_idp_secret() {
upsert_oauth_secret IDP_CLIENT_SECRET IDP_CLIENT_SECRET_NAME \
"${PREFIX}-idp-client-secret" "IdP client"
}

# 3LO providers (module 4): GOOGLE/GITHUB/NOTION_CLIENT_SECRET in the
# environment is moved to Secrets Manager the same way — app.py refuses the
# plaintext form outright.
upsert_3lo_secrets() {
upsert_oauth_secret GOOGLE_CLIENT_SECRET GOOGLE_CLIENT_SECRET_NAME \
"${PREFIX}-google-oauth-secret" "Google OAuth"
upsert_oauth_secret GITHUB_CLIENT_SECRET GITHUB_CLIENT_SECRET_NAME \
"${PREFIX}-github-oauth-secret" "GitHub OAuth"
upsert_oauth_secret NOTION_CLIENT_SECRET NOTION_CLIENT_SECRET_NAME \
"${PREFIX}-notion-oauth-secret" "Notion OAuth"
}

# ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -541,6 +570,14 @@ build_context_args() {
[ -n "${IDP_CLIENT_SECRET_NAME:-}" ] && CONTEXT_ARGS+=(-c "idp_client_secret_name=${IDP_CLIENT_SECRET_NAME}")
[ -n "${IDP_ISSUER_URL:-}" ] && CONTEXT_ARGS+=(-c "idp_issuer_url=${IDP_ISSUER_URL}")

# 3LO provider config — same rule: secret NAMES only (upsert_3lo_secrets).
[ -n "${GOOGLE_CLIENT_ID:-}" ] && CONTEXT_ARGS+=(-c "google_client_id=${GOOGLE_CLIENT_ID}")
[ -n "${GOOGLE_CLIENT_SECRET_NAME:-}" ] && CONTEXT_ARGS+=(-c "google_client_secret_name=${GOOGLE_CLIENT_SECRET_NAME}")
[ -n "${GITHUB_CLIENT_ID:-}" ] && CONTEXT_ARGS+=(-c "github_client_id=${GITHUB_CLIENT_ID}")
[ -n "${GITHUB_CLIENT_SECRET_NAME:-}" ] && CONTEXT_ARGS+=(-c "github_client_secret_name=${GITHUB_CLIENT_SECRET_NAME}")
[ -n "${NOTION_CLIENT_ID:-}" ] && CONTEXT_ARGS+=(-c "notion_client_id=${NOTION_CLIENT_ID}")
[ -n "${NOTION_CLIENT_SECRET_NAME:-}" ] && CONTEXT_ARGS+=(-c "notion_client_secret_name=${NOTION_CLIENT_SECRET_NAME}")

# Feature flags from profile
[ -n "${ENABLE_NETWORKING:-}" ] && CONTEXT_ARGS+=(-c "enable_networking=${ENABLE_NETWORKING}")
[ -n "${ENABLE_SECURITY:-}" ] && CONTEXT_ARGS+=(-c "enable_security=${ENABLE_SECURITY}")
Expand Down Expand Up @@ -827,9 +864,13 @@ fi

cd "$PROJECT_DIR"

# If IDP_CLIENT_SECRET came from the environment, move it into Secrets Manager
# before any context args are built (plaintext never reaches the CDK CLI).
[ "$DRY_RUN" = "1" ] || upsert_idp_secret
# If IDP_CLIENT_SECRET or a 3LO *_CLIENT_SECRET came from the environment, move
# it into Secrets Manager before any context args are built (plaintext never
# reaches the CDK CLI).
if [ "$DRY_RUN" != "1" ]; then
upsert_idp_secret
upsert_3lo_secrets
fi

# Build CDK context args (populates the CONTEXT_ARGS array)
build_context_args
Expand Down
Loading
Loading