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
36 changes: 35 additions & 1 deletion .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ jobs:
directory:
- terraform
- terraform/examples/basic
- terraform/examples/function-url
- terraform/examples/with-ssm-bootstrap
steps:
- name: Checkout
Expand All @@ -73,6 +72,41 @@ jobs:
working-directory: ${{ matrix.directory }}
run: terraform validate -no-color

- name: terraform test
if: matrix.directory == 'terraform'
working-directory: terraform
run: terraform test -no-color

- name: removed Function URL input is rejected
if: matrix.directory == 'terraform'
shell: bash
run: |
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

cat > "$tmpdir/main.tf" <<EOF
module "broker" {
source = "${GITHUB_WORKSPACE}/terraform"

function_name = "github-token-broker"
repository_owner = "acme"
repository_name = "widgets"
enable_function_url = true

lambda_artifact = {
lambda_zip_path = "/tmp/github-token-broker.zip"
}
}
EOF

terraform -chdir="$tmpdir" init -backend=false -input=false
if terraform -chdir="$tmpdir" validate -no-color >"$tmpdir/validate.out" 2>&1; then
echo "expected enable_function_url to be rejected" >&2
exit 1
fi

grep -q "Unsupported argument" "$tmpdir/validate.out"

lint:
runs-on: ubuntu-latest
permissions:
Expand Down
8 changes: 5 additions & 3 deletions docs/docs/explanation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ flowchart LR
end
GitHub["GitHub API<br/>(github.com or GHES)"]
Caller -->|InvokeFunction<br/>empty payload| Lambda
Lambda -->|POST /app/installations/<br/>&#123;id&#125;/access_tokens| GitHub
Lambda -->|GET /repos/owner/repo/installation<br/>POST /app/installations/.../access_tokens| GitHub
Lambda -->|token JSON| Caller
```

Expand All @@ -45,9 +45,11 @@ sequenceDiagram
L->>S: GetParameters (3 names, WithDecryption)
S-->>L: client_id, installation_id, private_key_pem
L->>L: Sign RS256 JWT<br/>(iss=client_id, iat=now-60s, exp=now+9min)
L->>G: GET /repos/{owner}/{repo}/installation
G-->>L: installation metadata
L->>L: Confirm installation id matches config
L->>G: POST /app/installations/{id}/access_tokens<br/>+ permissions, repositories
G-->>L: { token, expires_at, ... }
L->>L: Validate returned repositories match config
L-->>C: { token, expires_at, repositories, permissions }
```

Expand All @@ -59,7 +61,7 @@ The broker is stateless. There is no database, no cache, no disk writes. Every f

## Cold start shape

On a cold start, Go bootstraps (fast — the binary is ~7 MB statically linked) and the first invocation runs the full mint flow. There is no warmup or prefetch. Steady-state invocation latency is dominated by three network round trips: SSM `GetParameters`, GitHub `POST /access_tokens`, and the invocation return. Each is typically sub-100 ms in the same AWS region.
On a cold start, Go bootstraps (fast — the binary is ~7 MB statically linked) and the first invocation runs the full mint flow. There is no warmup or prefetch. Steady-state invocation latency is dominated by SSM `GetParameters`, GitHub repository-installation preflight, GitHub `POST /access_tokens`, and the invocation return. Each is typically sub-100 ms in the same AWS region.

## Boundaries

Expand Down
3 changes: 2 additions & 1 deletion docs/docs/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The broker's purpose is to narrow the surface over which a GitHub App's long-liv

- **Token theft from caller environments.** Callers only ever see short-lived installation tokens, not the PEM. Stealing a token buys the attacker roughly one hour on one repository with one permission set — and the window closes on its own.
- **Scope escalation via caller input.** The scope of a minted token — repository, permissions — is fixed at deploy time. A caller cannot request a wider scope. See [Why empty payloads are enforced](./why-empty-payloads).
- **Credential exfiltration via logs.** The broker never logs the minted token. CloudWatch Logs for the function contain only the repositories and expiration time. The PEM is never logged under any code path.
- **Credential exfiltration via logs.** The broker never logs the minted token. Success logs contain only the repositories and expiration time, and GitHub error response bodies are not copied into failure logs. The PEM is never logged under any code path.
- **Casual access to the PEM at rest.** The PEM is stored as an SSM `SecureString`, encrypted with either the AWS-managed SSM key or a customer-managed KMS key. Reading the parameter value requires `ssm:GetParameters` with `WithDecryption`.

### Not defended against
Expand Down Expand Up @@ -49,6 +49,7 @@ Both are under the AWS account's control. An attacker with the ability to modify
- The PEM is never emitted to logs, traces, or response bodies.
- The token is never emitted to logs, traces, or any place other than the invocation response.
- IAM access to the three SSM parameters is tightly scoped — no wildcards, no `GetParameter` (singular), no write actions. See [IAM permissions](../reference/iam-permissions).
- Terraform and runtime configuration reject wildcard SSM paths; Terraform rejects wildcard KMS ARNs; the GitHub client rejects non-HTTPS API URLs except loopback `http` for local tests.

## See also

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/how-to/use-with-github-enterprise-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Point the broker at a GitHub Enterprise Server instance instead of

# Use with GitHub Enterprise Server

The broker targets `https://api.github.com` by default. Overriding the API base URL points it at GitHub Enterprise Server (GHES).
The broker targets `https://api.github.com` by default. Overriding the API base URL points it at GitHub Enterprise Server (GHES). The GHES API URL must use `https`; plain `http` is rejected except for loopback URLs used by local tests.

## Before you start

Expand Down
14 changes: 7 additions & 7 deletions docs/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ The broker reads all runtime configuration from environment variables. When depl
| Variable | Required | Default | Notes |
|---|---|---|---|
| `AWS_REGION` | Yes | — | Provided by the Lambda runtime. Required for SDK initialization. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_OWNER` | Yes | — | GitHub owner the minted token is scoped to. Trimmed. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_NAME` | Yes | — | GitHub repository name the minted token is scoped to. Trimmed. |
| `GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM` | No | `/github-token-broker/app/client-id` | SSM parameter path for the GitHub App client ID. Must be absolute (start with `/`). |
| `GITHUB_TOKEN_BROKER_INSTALLATION_ID_PARAM` | No | `/github-token-broker/app/installation-id` | SSM parameter path for the installation ID. Must be absolute. |
| `GITHUB_TOKEN_BROKER_PRIVATE_KEY_PARAM` | No | `/github-token-broker/app/private-key-pem` | SSM SecureString parameter path for the private key PEM. Must be absolute. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_OWNER` | Yes | — | GitHub owner the minted token is scoped to. Trimmed; may contain only letters, numbers, periods, underscores, and hyphens. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_NAME` | Yes | — | GitHub repository name the minted token is scoped to. Trimmed; may contain only letters, numbers, periods, underscores, and hyphens. |
| `GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM` | No | `/github-token-broker/app/client-id` | SSM parameter path for the GitHub App client ID. Must be an absolute literal path. |
| `GITHUB_TOKEN_BROKER_INSTALLATION_ID_PARAM` | No | `/github-token-broker/app/installation-id` | SSM parameter path for the installation ID. Must be an absolute literal path. |
| `GITHUB_TOKEN_BROKER_PRIVATE_KEY_PARAM` | No | `/github-token-broker/app/private-key-pem` | SSM SecureString parameter path for the private key PEM. Must be an absolute literal path. |
| `GITHUB_TOKEN_BROKER_PERMISSIONS` | No | `{"contents":"read"}` | JSON object of string-to-string permission entries. Must parse to a non-empty object; keys and values must be non-empty. |
| `GITHUB_TOKEN_BROKER_GITHUB_API_BASE_URL` | No | `https://api.github.com` | GitHub API base URL. Override for GitHub Enterprise Server. |
| `GITHUB_TOKEN_BROKER_GITHUB_API_BASE_URL` | No | `https://api.github.com` | GitHub API base URL. Override for GitHub Enterprise Server. Must use `https` except for loopback `http` URLs used in local tests. |
| `GITHUB_TOKEN_BROKER_LOG_LEVEL` | No | `info` | One of `debug`, `info`, `warn`, `error`. Passed to `slog`. |

## Notes

- `AWS_REGION` is reserved by the Lambda runtime and injected automatically. Do not set it in Terraform; the broker's configuration loader reads it from the process environment like any other variable.
- SSM parameter paths are validated at startup. A non-absolute path causes the process to exit before taking traffic.
- SSM parameter paths are validated at startup. They must start with `/` and contain only letters, numbers, periods, underscores, hyphens, and slashes; wildcard characters are rejected.
- The private-key parameter **must** be `SecureString` so SSM returns it encrypted and the broker decrypts it in-flight.
- An empty or missing `GITHUB_TOKEN_BROKER_PERMISSIONS` falls back to `{"contents":"read"}`.

Expand Down
11 changes: 7 additions & 4 deletions docs/docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ All broker errors are returned as Lambda function errors (not as a `200` with an
| `AWS_REGION is required` | The Lambda started without a region in the environment. | Should never happen under the Lambda runtime. If reproducing locally, set `AWS_REGION`. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_OWNER is required` | Required config missing. | Set the Terraform module's `repository_owner` input. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_NAME is required` | Required config missing. | Set the Terraform module's `repository_name` input. |
| `GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM must be an absolute SSM parameter path` | SSM path does not start with `/`. Applies to all three parameter-path variables. | Use an absolute path in the module's `ssm_parameter_paths` input. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_OWNER contains unsupported characters` | Owner contains path separators, escapes, or another unsupported character. | Use the literal GitHub owner only: letters, numbers, periods, underscores, and hyphens. |
| `GITHUB_TOKEN_BROKER_REPOSITORY_NAME contains unsupported characters` | Repository name contains path separators, escapes, or another unsupported character. | Use the literal GitHub repository name only: letters, numbers, periods, underscores, and hyphens. |
| `GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM must be an absolute literal SSM parameter path` | SSM path is relative or contains unsupported characters. Applies to all three parameter-path variables. | Use an absolute literal path in the module's `ssm_parameter_paths` input; do not use `*` or `?`. |
| `GitHub API base URL must use https unless the host is loopback` | `GITHUB_TOKEN_BROKER_GITHUB_API_BASE_URL` points to a non-loopback `http` URL. | Use `https` for github.com and GHES. Plain `http` is only accepted for loopback local tests. |
| `GITHUB_TOKEN_BROKER_PERMISSIONS must be a JSON object of string-to-string entries` | `GITHUB_TOKEN_BROKER_PERMISSIONS` is not valid JSON or has non-string values. | Supply a JSON object like `{"contents":"read"}`. |
| `missing GitHub App SSM parameters: [...]` | One or more SSM parameters do not exist at the configured paths. | Create the parameters (see [SSM parameter shapes](./ssm-parameter-shapes)), or fix the paths. |
| SSM `AccessDeniedException` | The Lambda's role lacks `ssm:GetParameters` on the parameter ARNs, or `kms:Decrypt` on the CMK. | Verify the module's IAM policy matches the parameter paths and CMK. See [IAM permissions](./iam-permissions). |
| JWT signing failure / PEM parse error | The private-key parameter value is not a valid PEM. Common causes: line endings mangled during `put-parameter`, wrong parameter updated, truncated file. | Re-upload the PEM with `$(cat key.pem)` to preserve content. See [rotate-github-app-private-key](../how-to/rotate-github-app-private-key). |
| GitHub `401 Unauthorized` on `/app/installations/{id}/access_tokens` | The private key does not match the App's active keys, or the client ID does not match the App. | Verify the PEM corresponds to a currently-active private key on the App, and the client ID is the App's client ID (not App ID). |
| GitHub `404 Not Found` on `/app/installations/{id}/access_tokens` | The installation ID is wrong, or the App was uninstalled from the repository. | Verify the installation ID, and that the App is installed on the target repo. |
| GitHub request failure with `status 401` | The private key does not match the App's active keys, or the client ID does not match the App. | Verify the PEM corresponds to a currently-active private key on the App, and the client ID is the App's client ID (not App ID). |
| GitHub request failure with `status 404` | The repository, installation ID, or GHES API base URL is wrong, or the App was uninstalled from the repository. | Verify the target repository, installation ID, and that the App is installed on the target repo. |
| Broker error mentioning the target repository | The configured repository is not covered by the installation's repository selection. | Update the App's installation to include the repo, or change the target (see [change-target-repository](../how-to/change-target-repository)). |

## Where errors surface

- **AWS CLI**: `FunctionError` on `aws lambda invoke`; the message is in the response body.
- **CloudWatch Logs**: every failure logs an `ERROR` line with the message. The token is never logged on success or failure.
- **CloudWatch Logs**: every failure logs an `ERROR` line with the message. The token is never logged on success or failure, and upstream GitHub response bodies are not copied into logs.
- **Caller code**: if invoking via the AWS SDK, errors surface as `Lambda.ServiceException`-family exceptions with the message in the `FunctionError` field.

## See also
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/reference/iam-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The Terraform module provisions the Lambda's execution role with a least-privile
}
```

The actual parameter paths come from the module's `ssm_parameter_paths` input and default to the paths shown above. Only `ssm:GetParameters` (plural) is granted — the broker fetches all three in one batched call.
The actual parameter paths come from the module's `ssm_parameter_paths` input and default to the paths shown above. The module accepts only absolute literal SSM paths and rejects wildcard characters so the generated ARN resources stay exact. Only `ssm:GetParameters` (plural) is granted — the broker fetches all three in one batched call.

### Decrypt the private key (conditional)

Expand All @@ -40,7 +40,7 @@ Present only when the module's `kms_key_arn` variable is set:
}
```

When the SecureString parameter uses the AWS-managed SSM key (`alias/aws/ssm`), this statement is omitted; the AWS-managed key grants decrypt via SSM's service principal automatically.
When the SecureString parameter uses the AWS-managed SSM key (`alias/aws/ssm`), this statement is omitted; the AWS-managed key grants decrypt via SSM's service principal automatically. When `kms_key_arn` is set, the module requires a literal KMS key or alias ARN and rejects wildcard characters.

### Write CloudWatch logs

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/reference/ssm-parameter-shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The broker reads three SSM parameters in a single `GetParameters` call with `Wit
| `/github-token-broker/app/installation-id` | `String` | The numeric installation ID as a string (e.g. `"12345678"`). Visible in the GitHub App's installation URL. |
| `/github-token-broker/app/private-key-pem` | `SecureString` | A PEM-encoded RSA private key, starting with `-----BEGIN RSA PRIVATE KEY-----`. The entire file contents, including the `BEGIN`/`END` lines and trailing newline. |

All three paths are overridable via environment variables — see [Environment variables](./environment-variables).
All three paths are overridable via environment variables — see [Environment variables](./environment-variables). Custom paths must be absolute literal SSM parameter names using only letters, numbers, periods, underscores, hyphens, and slashes; wildcard characters are rejected because these paths are also rendered into IAM resource ARNs.

## Client ID, not App ID

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/tutorials/deploy-your-first-broker.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The response should be your repository name.

## What just happened

On each invocation the Lambda reads the three SSM parameters in one batched call (with decryption), signs an RS256 JWT valid for 9 minutes with a 60-second backdated `iat`, exchanges the JWT for an installation token via `POST /app/installations/{id}/access_tokens`, and returns the token to you. It is stateless — nothing is cached across invocations. See [Architecture](../explanation/architecture) for the full diagram.
On each invocation the Lambda reads the three SSM parameters in one batched call (with decryption), signs an RS256 JWT valid for 9 minutes with a 60-second backdated `iat`, verifies that the configured repository belongs to the configured installation, exchanges the JWT for an installation token via `POST /app/installations/{id}/access_tokens`, and returns the token to you. It is stateless — nothing is cached across invocations. See [Architecture](../explanation/architecture) for the full diagram.

## Next steps

Expand Down
33 changes: 24 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
)

Expand All @@ -19,6 +20,11 @@ const (
defaultPermissionLevel = "read"
)

var (
githubLiteralNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
ssmParameterPathPattern = regexp.MustCompile(`^/[A-Za-z0-9_.\-/]+$`)
)

// Config is the runtime configuration for github-token-broker.
type Config struct {
// AWSRegion is the AWS region used for SDK configuration.
Expand All @@ -43,9 +49,10 @@ type Config struct {

// Load reads environment variables into a Config.
//
// Load returns an error when required variables are missing, when SSM parameter
// paths are not absolute, or when the permissions environment variable does not
// parse into a non-empty JSON object of string-to-string entries.
// Load returns an error when required variables are missing, when repository
// names or SSM parameter paths contain unsupported characters, or when the
// permissions environment variable does not parse into a non-empty JSON object
// of string-to-string entries.
func Load() (Config, error) {
cfg := Config{
AWSRegion: os.Getenv("AWS_REGION"),
Expand All @@ -62,26 +69,34 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("AWS_REGION is required")
}

if !strings.HasPrefix(cfg.ClientIDParameter, "/") {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM must be an absolute SSM parameter path")
if !ssmParameterPathPattern.MatchString(cfg.ClientIDParameter) {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_CLIENT_ID_PARAM must be an absolute literal SSM parameter path")
}

if !strings.HasPrefix(cfg.InstallationIDParameter, "/") {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_INSTALLATION_ID_PARAM must be an absolute SSM parameter path")
if !ssmParameterPathPattern.MatchString(cfg.InstallationIDParameter) {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_INSTALLATION_ID_PARAM must be an absolute literal SSM parameter path")
}

if !strings.HasPrefix(cfg.PrivateKeyParameter, "/") {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_PRIVATE_KEY_PARAM must be an absolute SSM parameter path")
if !ssmParameterPathPattern.MatchString(cfg.PrivateKeyParameter) {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_PRIVATE_KEY_PARAM must be an absolute literal SSM parameter path")
}

if cfg.RepositoryOwner == "" {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_REPOSITORY_OWNER is required")
}

if !githubLiteralNamePattern.MatchString(cfg.RepositoryOwner) {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_REPOSITORY_OWNER contains unsupported characters")
}

if cfg.RepositoryName == "" {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_REPOSITORY_NAME is required")
}

if !githubLiteralNamePattern.MatchString(cfg.RepositoryName) {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_REPOSITORY_NAME contains unsupported characters")
}

if cfg.GitHubAPIBaseURL == "" {
return Config{}, fmt.Errorf("GITHUB_TOKEN_BROKER_GITHUB_API_BASE_URL must not be empty")
}
Expand Down
Loading
Loading