Merged in feature/service-accounts-1 (pull request #227)

Support for service accounts with static credentials

* feature complete

* tests and docs

* lint fix
This commit is contained in:
Jay Brown
2026-05-21 19:40:04 +00:00
parent e3d9047143
commit 451da3d26d
63 changed files with 4347 additions and 11179 deletions
@@ -0,0 +1,547 @@
// Package main implements the service-account lifecycle CLI for the
// two-pool Cognito plan
// (plans/api.key.support.cognito.two-pool.plan.3.claude.md §4.5).
//
// Subcommands:
//
// create <username> create Cognito user in pool B + Permit registration
// show <username> print Cognito attributes + Permit roles
// list list users in pool B
// disable <username> AdminDisableUser + AdminUserGlobalSignOut
// enable <username> AdminEnableUser
// rotate-password <username> AdminSetUserPassword (permanent, new random)
// delete <username> AdminDeleteUser + DeletePermitUser
// register-permit <username> [--roles r1,r2]
// register an existing Cognito service-account
// user in Permit and assign roles (used to
// bind the operator-pre-created test account
// to a super_admin role without re-creating
// it).
//
// Configuration comes from environment variables:
//
// COGNITO_REGION AWS region (defaults to us-east-2)
// COGNITO_SVC_USER_POOL_ID pool B id (required; tool refuses to run when empty)
// COGNITO_SVC_APP_CLIENT_ID pool B app client id (only used by create flow when --print-test-auth is set)
// PERMIT_IO_API_KEY permit.io API key (project or env scope)
// PERMIT_IO_BASE_URL permit.io API base URL (defaults to https://api.permit.io)
// AWS_PROFILE optional AWS profile name
//
// The tool is deliberately small — it wraps the AWS SDK and the
// Permit.io HTTP API directly and reuses the JSON shapes the
// user.creation.tool already proved work.
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types"
)
// Settings captures the runtime configuration read from env vars. It
// is intentionally small — every consumer reads what it needs and
// nothing more.
type Settings struct {
Region string
UserPoolID string
AppClientID string
PermitAPIKey string
PermitBaseURL string
AWSProfile string
}
// load pulls Settings from environment and validates the
// must-be-set fields. Missing values produce a single error rather
// than a partial run.
func load() (*Settings, error) {
s := &Settings{
Region: envDefault("COGNITO_REGION", "us-east-2"),
UserPoolID: os.Getenv("COGNITO_SVC_USER_POOL_ID"),
AppClientID: os.Getenv("COGNITO_SVC_APP_CLIENT_ID"),
PermitAPIKey: os.Getenv("PERMIT_IO_API_KEY"),
PermitBaseURL: envDefault("PERMIT_IO_BASE_URL", "https://api.permit.io"),
AWSProfile: os.Getenv("AWS_PROFILE"),
}
if s.UserPoolID == "" {
return nil, fmt.Errorf("COGNITO_SVC_USER_POOL_ID is required (service-accounts pool id)")
}
return s, nil
}
func envDefault(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// newCognitoClient builds an SDK client honoring AWS_PROFILE when
// present. We deliberately do not embed credentials in flags — the
// tool runs from an operator workstation with `aws sso login` or an
// equivalent shared-credentials setup.
func newCognitoClient(ctx context.Context, s *Settings) (*cognitoidentityprovider.Client, error) {
opts := []func(*config.LoadOptions) error{
config.WithRegion(s.Region),
}
if s.AWSProfile != "" {
opts = append(opts, config.WithSharedConfigProfile(s.AWSProfile))
}
cfg, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
return cognitoidentityprovider.NewFromConfig(cfg), nil
}
// generatePassword returns a 32-character password meeting the pool
// B password policy (upper, lower, digit, symbol). We use crypto/rand
// for the random bytes and stitch in one of each required class to
// guarantee compliance — naive base64 would occasionally miss a class
// and fail AdminSetUserPassword.
func generatePassword() (string, error) {
// 24 random bytes -> 32 base64 chars; we prepend four mandatory
// characters (Aa1!) so AdminSetUserPassword never rejects the
// password for failing a class requirement.
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
enc := base64.RawURLEncoding.EncodeToString(b)
if len(enc) > 28 {
enc = enc[:28]
}
return "A1a!" + enc, nil
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd := os.Args[1]
args := os.Args[2:]
s, err := load()
if err != nil {
exit(err)
}
ctx := context.Background()
client, err := newCognitoClient(ctx, s)
if err != nil {
exit(err)
}
switch cmd {
case "create":
exit(cmdCreate(ctx, client, s, args))
case "show":
exit(cmdShow(ctx, client, s, args))
case "list":
exit(cmdList(ctx, client, s, args))
case "disable":
exit(cmdDisable(ctx, client, s, args))
case "enable":
exit(cmdEnable(ctx, client, s, args))
case "rotate-password":
exit(cmdRotate(ctx, client, s, args))
case "delete":
exit(cmdDelete(ctx, client, s, args))
case "register-permit":
exit(cmdRegisterPermit(ctx, client, s, args))
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: service.account.tool <command> [args]")
fmt.Fprintln(os.Stderr, "commands:")
fmt.Fprintln(os.Stderr, " create <username> [--description text] [--env-id id] [--roles r1,r2]")
fmt.Fprintln(os.Stderr, " show <username>")
fmt.Fprintln(os.Stderr, " list")
fmt.Fprintln(os.Stderr, " disable <username>")
fmt.Fprintln(os.Stderr, " enable <username>")
fmt.Fprintln(os.Stderr, " rotate-password <username>")
fmt.Fprintln(os.Stderr, " delete <username>")
fmt.Fprintln(os.Stderr, " register-permit <username> [--roles r1,r2]")
}
func exit(err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
// --- commands -----------------------------------------------------
// cmdCreate creates a Cognito user in pool B, sets a permanent
// password, and registers a matching Permit.io user. The password is
// printed once on success and discarded by the tool — the operator is
// responsible for copying it into their secret store.
func cmdCreate(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
fs := flag.NewFlagSet("create", flag.ExitOnError)
desc := fs.String("description", "", "human description of the service account")
envID := fs.String("env-id", "", "Cognito environment id to tag the user with")
rolesArg := fs.String("roles", "", "comma-separated Permit.io roles to assign")
_ = fs.Parse(args)
if fs.NArg() < 1 {
return fmt.Errorf("create: <username> is required")
}
username := fs.Arg(0)
// 1. AdminCreateUser
attrs := []types.AttributeType{
{Name: aws.String("email"), Value: aws.String(username + "@svcacct.local")},
{Name: aws.String("email_verified"), Value: aws.String("true")},
{Name: aws.String("name"), Value: aws.String(username)},
}
if *desc != "" {
attrs = append(attrs, types.AttributeType{
Name: aws.String("custom:description"),
Value: aws.String(*desc),
})
}
if *envID != "" {
attrs = append(attrs, types.AttributeType{
Name: aws.String("custom:environment_id"),
Value: aws.String(*envID),
})
}
attrs = append(attrs, types.AttributeType{
Name: aws.String("custom:is_service_account"),
Value: aws.String("true"),
})
created, err := c.AdminCreateUser(ctx, &cognitoidentityprovider.AdminCreateUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
UserAttributes: attrs,
MessageAction: types.MessageActionTypeSuppress,
})
if err != nil {
return fmt.Errorf("AdminCreateUser: %w", err)
}
sub := extractAttr(created.User.Attributes, "sub")
// 2. Permanent password
password, err := generatePassword()
if err != nil {
// Clean up the half-created user so we don't leave debris.
_, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
return fmt.Errorf("generatePassword: %w", err)
}
_, err = c.AdminSetUserPassword(ctx, &cognitoidentityprovider.AdminSetUserPasswordInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
Password: aws.String(password),
Permanent: true,
})
if err != nil {
_, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
return fmt.Errorf("AdminSetUserPassword: %w", err)
}
// 3. Register in Permit. Any failure beyond this point must roll
// back the Cognito user so we never leave an enabled service
// account with a permanent password that was never surfaced to
// the operator (plan §6.2). assignRole failures additionally
// roll back the Permit user record so Permit state stays in sync
// with Cognito.
rollbackCognito := func() {
_, _ = c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
}
if s.PermitAPIKey != "" {
scope, err := getAPIKeyScope(s)
if err != nil {
rollbackCognito()
return fmt.Errorf("permit scope: %w", err)
}
if err := createPermitUser(s, scope, sub, username, *envID); err != nil {
rollbackCognito()
return fmt.Errorf("createPermitUser: %w", err)
}
if *rolesArg != "" {
for _, r := range splitCSV(*rolesArg) {
if err := assignRole(s, scope, sub, r); err != nil {
_ = deletePermitUser(s, scope, sub)
rollbackCognito()
return fmt.Errorf("assignRole %s: %w", r, err)
}
}
}
} else {
fmt.Fprintln(os.Stderr, "warning: PERMIT_IO_API_KEY not set; skipping Permit registration")
}
fmt.Println(strings.Repeat("=", 64))
fmt.Println("Service account created. RECORD THE PASSWORD NOW.")
fmt.Println(strings.Repeat("=", 64))
fmt.Printf("Username : %s\n", username)
fmt.Printf("Sub : %s\n", sub)
fmt.Printf("Pool id : %s\n", s.UserPoolID)
fmt.Printf("Client id: %s\n", s.AppClientID)
fmt.Printf("Password : %s\n", password)
return nil
}
// cmdShow prints the user's Cognito attributes and Permit roles.
func cmdShow(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
if len(args) < 1 {
return fmt.Errorf("show: <username> required")
}
username := args[0]
resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
if err != nil {
return fmt.Errorf("AdminGetUser: %w", err)
}
fmt.Printf("Username : %s\n", aws.ToString(resp.Username))
fmt.Printf("Status : %s\n", resp.UserStatus)
fmt.Printf("Enabled : %v\n", resp.Enabled)
for _, a := range resp.UserAttributes {
fmt.Printf(" %-30s = %s\n", aws.ToString(a.Name), aws.ToString(a.Value))
}
sub := extractAttr(resp.UserAttributes, "sub")
if s.PermitAPIKey != "" && sub != "" {
scope, err := getAPIKeyScope(s)
if err != nil {
fmt.Fprintf(os.Stderr, "permit scope: %v\n", err)
return nil
}
roles, err := getPermitRoles(s, scope, sub)
if err != nil {
fmt.Fprintf(os.Stderr, "permit roles: %v\n", err)
return nil
}
fmt.Printf("Permit roles : %s\n", strings.Join(roles, ", "))
}
return nil
}
// cmdList enumerates users in pool B. ListUsers is paginated, so we
// follow PaginationToken; we cap at 1000 entries to keep the tool
// from accidentally pulling massive output on a misconfigured pool.
func cmdList(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, _ []string) error {
const max = 1000
var token *string
count := 0
for count < max {
resp, err := c.ListUsers(ctx, &cognitoidentityprovider.ListUsersInput{
UserPoolId: aws.String(s.UserPoolID),
PaginationToken: token,
})
if err != nil {
return fmt.Errorf("ListUsers: %w", err)
}
for _, u := range resp.Users {
fmt.Printf("%-30s sub=%s status=%s enabled=%v\n",
aws.ToString(u.Username),
extractAttr(u.Attributes, "sub"),
u.UserStatus, u.Enabled)
count++
if count >= max {
break
}
}
if resp.PaginationToken == nil || *resp.PaginationToken == "" {
break
}
token = resp.PaginationToken
}
return nil
}
// cmdDisable disables the user and revokes outstanding refresh
// tokens. Outstanding access tokens remain valid until their iat+TTL
// elapses; the pool's AccessTokenValidity bounds the window.
func cmdDisable(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
if len(args) < 1 {
return fmt.Errorf("disable: <username> required")
}
username := args[0]
if _, err := c.AdminDisableUser(ctx, &cognitoidentityprovider.AdminDisableUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
}); err != nil {
return fmt.Errorf("AdminDisableUser: %w", err)
}
if _, err := c.AdminUserGlobalSignOut(ctx, &cognitoidentityprovider.AdminUserGlobalSignOutInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
}); err != nil {
return fmt.Errorf("AdminUserGlobalSignOut: %w", err)
}
fmt.Printf("Disabled %s. Outstanding access tokens valid until exp.\n", username)
return nil
}
func cmdEnable(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
if len(args) < 1 {
return fmt.Errorf("enable: <username> required")
}
username := args[0]
if _, err := c.AdminEnableUser(ctx, &cognitoidentityprovider.AdminEnableUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
}); err != nil {
return fmt.Errorf("AdminEnableUser: %w", err)
}
fmt.Printf("Enabled %s\n", username)
return nil
}
func cmdRotate(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
if len(args) < 1 {
return fmt.Errorf("rotate-password: <username> required")
}
username := args[0]
password, err := generatePassword()
if err != nil {
return err
}
if _, err := c.AdminSetUserPassword(ctx, &cognitoidentityprovider.AdminSetUserPasswordInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
Password: aws.String(password),
Permanent: true,
}); err != nil {
return fmt.Errorf("AdminSetUserPassword: %w", err)
}
fmt.Printf("New password for %s: %s\n", username, password)
return nil
}
func cmdDelete(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
if len(args) < 1 {
return fmt.Errorf("delete: <username> required")
}
username := args[0]
// Look up sub first so we can clean up Permit afterwards.
resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
if err != nil {
return fmt.Errorf("AdminGetUser: %w", err)
}
sub := extractAttr(resp.UserAttributes, "sub")
if _, err := c.AdminDeleteUser(ctx, &cognitoidentityprovider.AdminDeleteUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
}); err != nil {
return fmt.Errorf("AdminDeleteUser: %w", err)
}
if s.PermitAPIKey != "" && sub != "" {
scope, err := getAPIKeyScope(s)
if err == nil {
_ = deletePermitUser(s, scope, sub)
}
}
fmt.Printf("Deleted %s (sub=%s)\n", username, sub)
return nil
}
// cmdRegisterPermit attaches an already-existing Cognito user (e.g.
// a service account created via shell scripts during the v1 bring-up)
// to a Permit.io project + roles. This is the path the integration
// test uses to make the test account a super_admin without creating
// a new Cognito user.
func cmdRegisterPermit(ctx context.Context, c *cognitoidentityprovider.Client, s *Settings, args []string) error {
fs := flag.NewFlagSet("register-permit", flag.ExitOnError)
rolesArg := fs.String("roles", "", "comma-separated Permit.io roles to assign")
_ = fs.Parse(args)
if fs.NArg() < 1 {
return fmt.Errorf("register-permit: <username> required")
}
username := fs.Arg(0)
if s.PermitAPIKey == "" {
return fmt.Errorf("PERMIT_IO_API_KEY must be set")
}
resp, err := c.AdminGetUser(ctx, &cognitoidentityprovider.AdminGetUserInput{
UserPoolId: aws.String(s.UserPoolID),
Username: aws.String(username),
})
if err != nil {
return fmt.Errorf("AdminGetUser: %w", err)
}
sub := extractAttr(resp.UserAttributes, "sub")
envID := extractAttr(resp.UserAttributes, "custom:environment_id")
scope, err := getAPIKeyScope(s)
if err != nil {
return fmt.Errorf("permit scope: %w", err)
}
if err := createPermitUser(s, scope, sub, username, envID); err != nil {
return fmt.Errorf("createPermitUser: %w", err)
}
for _, r := range splitCSV(*rolesArg) {
if err := assignRole(s, scope, sub, r); err != nil {
return fmt.Errorf("assignRole %s: %w", r, err)
}
}
fmt.Printf("Registered %s (sub=%s) in Permit with roles=[%s]\n", username, sub, *rolesArg)
return nil
}
// --- helpers ------------------------------------------------------
func extractAttr(attrs []types.AttributeType, name string) string {
for _, a := range attrs {
if aws.ToString(a.Name) == name {
return aws.ToString(a.Value)
}
}
return ""
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// httpClient returns a shared HTTP client with a sensible timeout
// for short-lived Permit.io API calls.
func httpClient() *http.Client {
return &http.Client{Timeout: 15 * time.Second}
}
@@ -0,0 +1,59 @@
package main
import (
"strings"
"testing"
)
// TestGeneratePassword sanity-checks the generated password meets
// the pool B policy: length >= 32 and every character class is
// present. Cognito will reject anything that misses a class so we
// verify in-process to catch regressions early.
func TestGeneratePassword(t *testing.T) {
for i := 0; i < 20; i++ {
p, err := generatePassword()
if err != nil {
t.Fatalf("generatePassword: %v", err)
}
if len(p) < 32 {
t.Errorf("password too short: %d chars", len(p))
}
if !strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
t.Errorf("missing uppercase in %q", p)
}
if !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") {
t.Errorf("missing lowercase in %q", p)
}
if !strings.ContainsAny(p, "0123456789") {
t.Errorf("missing digit in %q", p)
}
if !strings.ContainsAny(p, "!@#$%^&*()_+-=[]{}|;:,.<>?/") {
t.Errorf("missing symbol in %q", p)
}
}
}
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{"a", []string{"a"}},
{"a,b,c", []string{"a", "b", "c"}},
{" a , b , c ", []string{"a", "b", "c"}},
{",,,", nil},
}
for _, tc := range cases {
got := splitCSV(tc.in)
if len(got) != len(tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
continue
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("splitCSV(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i])
}
}
}
}
@@ -0,0 +1,169 @@
// permit.go wraps the small slice of the Permit.io HTTP API the
// service-account CLI needs. We keep it self-contained rather than
// importing user.creation.tool's helpers because that package is a
// `main` and can't be reused.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// permitScope is what /v2/api-key/scope returns. We use it to figure
// out which project + environment a project-or-env-scoped API key is
// bound to.
type permitScope struct {
OrganizationID string `json:"organization_id"`
ProjectID string `json:"project_id"`
EnvironmentID string `json:"environment_id"`
}
func getAPIKeyScope(s *Settings) (*permitScope, error) {
req, err := http.NewRequest("GET", s.PermitBaseURL+"/v2/api-key/scope", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("permit scope status=%d body=%s", resp.StatusCode, string(body))
}
var sc permitScope
if err := json.Unmarshal(body, &sc); err != nil {
return nil, err
}
if sc.ProjectID == "" || sc.EnvironmentID == "" {
return nil, fmt.Errorf("permit scope must be project+env; got project=%q env=%q", sc.ProjectID, sc.EnvironmentID)
}
return &sc, nil
}
// createPermitUser idempotently registers the service account's
// Cognito sub as a Permit user. Conflicts are treated as success so
// repeated runs of the tool stay safe.
func createPermitUser(s *Settings, scope *permitScope, sub, username, envID string) error {
attrs := map[string]interface{}{
"cognito_username": username,
"is_service_account": true,
}
if envID != "" {
attrs["environment_id"] = envID
}
// Permit.io validates the email field against a real-TLD list and
// rejects the @svcacct.local synthetic address with 422. The
// username is captured in attrs.cognito_username, so we omit
// `email` entirely from the registration payload.
body := map[string]interface{}{
"key": sub,
"first_name": username,
"last_name": "service-account",
"attributes": attrs,
}
return doJSON(s, "POST",
fmt.Sprintf("%s/v2/facts/%s/%s/users", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID),
body, []int{200, 201, 409})
}
func deletePermitUser(s *Settings, scope *permitScope, sub string) error {
url := fmt.Sprintf("%s/v2/facts/%s/%s/users/%s", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID, sub)
return doJSON(s, "DELETE", url, nil, []int{200, 204, 404})
}
func assignRole(s *Settings, scope *permitScope, sub, role string) error {
body := map[string]interface{}{
"user": sub,
"role": role,
"tenant": "default",
}
return doJSON(s, "POST",
fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments", s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID),
body, []int{200, 201, 409})
}
// getPermitRoles enumerates the user's role assignments. Used by the
// `show` command for human-readable output.
func getPermitRoles(s *Settings, scope *permitScope, sub string) ([]string, error) {
url := fmt.Sprintf("%s/v2/facts/%s/%s/role_assignments?user=%s",
s.PermitBaseURL, scope.ProjectID, scope.EnvironmentID, sub)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey)
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("get roles status=%d body=%s", resp.StatusCode, string(body))
}
// Permit returns a direct array of assignments.
var arr []map[string]interface{}
if err := json.Unmarshal(body, &arr); err == nil {
out := make([]string, 0, len(arr))
for _, a := range arr {
if r, ok := a["role"].(string); ok {
out = append(out, r)
}
}
return out, nil
}
// Some Permit shards wrap in { "data": [...] } — fall back.
var wrapped struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err != nil {
return nil, fmt.Errorf("unexpected role assignment response shape")
}
out := make([]string, 0, len(wrapped.Data))
for _, a := range wrapped.Data {
if r, ok := a["role"].(string); ok {
out = append(out, r)
}
}
return out, nil
}
// doJSON sends a JSON request and accepts any status in `okStatus`.
// Non-accepted statuses are surfaced verbatim so callers can see what
// Permit returned.
func doJSON(s *Settings, method, url string, payload interface{}, okStatus []int) error {
var bodyReader io.Reader
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return err
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+s.PermitAPIKey)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
for _, code := range okStatus {
if resp.StatusCode == code {
return nil
}
}
return fmt.Errorf("%s %s status=%d body=%s", method, url, resp.StatusCode, string(body))
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# run.tool.sh wraps service.account.tool with the env vars it needs
# against the local/dev pool B. Real Permit + AWS credentials must be
# present in the environment before invocation. Safe to invoke from any
# working directory — it anchors `go run` to its own location below.
set -euo pipefail
# Anchor `go run ./...` to the package this script lives in. Without
# this cd, callers in other directories (e.g. the python wrappers under
# scripts/manual_tests/ that shell out via subprocess.run) end up
# resolving ./... against their own CWD and hit
# `go: no packages loaded from ./...`.
cd "$(dirname "${BASH_SOURCE[0]}")"
# Service-accounts (pool B) — values from
# cmd/auth_related/service.accounts/creation.scripts/log.txt
export COGNITO_REGION="us-east-2"
export COGNITO_SVC_USER_POOL_ID="us-east-2_Qom1i9qIG"
export COGNITO_SVC_APP_CLIENT_ID="6r7chkvjotgs2bsfbd5ibaaft7"
# Permit.io API key — operator supplies via shell. The dev key from
# cmd/auth_related/user.creation.tool/run.tool.sh is the common one
# during local bring-up.
: "${PERMIT_IO_API_KEY:?set PERMIT_IO_API_KEY before running}"
export PERMIT_IO_BASE_URL="${PERMIT_IO_BASE_URL:-https://api.permit.io}"
# AWS — falls back to whatever the operator has authenticated as.
export AWS_REGION="${AWS_REGION:-us-east-2}"
go run ./... "$@"
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
#
# create.pool.and.client.sh
#
# Provision the service-accounts Cognito user pool ("Pool B") and its single
# app client, per plans/api.key.support.cognito.two-pool.plan.3.claude.md §4.1.
#
# Run this from AWS CloudShell while authenticated as an admin in the target
# account. No --profile flag is passed because CloudShell uses the console
# session's credentials.
#
# Idempotency: this script does NOT check for an existing pool of the same
# name. Cognito permits duplicate pool names and will silently create a second
# one. If a re-run is needed, delete the previous pool first or change
# POOL_NAME below.
#
# Outputs (to stdout and to LOG_FILE):
# - User pool id
# - User pool issuer URL (use as COGNITO_SVC_USER_POOL_ID source)
# - JWKS URL (use as COGNITO_SVC_JWKS_URL)
# - App client id (use as COGNITO_SVC_APP_CLIENT_ID)
#
# Requires: aws cli v2, jq (both pre-installed in CloudShell).
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration. Adjust AWS_REGION if the target account does not run in
# us-east-2.
# ---------------------------------------------------------------------------
AWS_REGION="${AWS_REGION:-us-east-2}"
POOL_NAME="aarete-service-test-pool-1"
APP_CLIENT_NAME="AareteServiceTestClient"
LOG_FILE="create.pool.and.client.log.txt"
# Per plan §4.1: 60-minute access/id tokens, 10-year refresh, four-class
# 32-char password policy. These match the values queryAPI expects.
ACCESS_TOKEN_VALIDITY_MIN=60
ID_TOKEN_VALIDITY_MIN=60
REFRESH_TOKEN_VALIDITY_DAYS=3650
PASSWORD_MIN_LENGTH=32
# ---------------------------------------------------------------------------
> "$LOG_FILE"
log() {
echo "$@" | tee -a "$LOG_FILE"
}
log "Region: $AWS_REGION"
log "Pool name: $POOL_NAME"
log "App client name: $APP_CLIENT_NAME"
log "---"
# ---------------------------------------------------------------------------
# 1. Create the user pool.
#
# Schema attributes (custom:*) declared up-front because Cognito will not let
# you add new custom attributes to an existing pool without re-creating
# clients. All four are mutable strings so the service-account CLI can set
# them at AdminCreateUser time.
#
# Notes:
# - --mfa-configuration OFF : no MFA on this pool
# - --admin-create-user-config : self-signup forbidden
# - --account-recovery-setting admin_only : no email recovery
# - --policies : 32-char four-class passwords
# ---------------------------------------------------------------------------
log "Creating Cognito user pool '$POOL_NAME'..."
USER_POOL_ID=$(aws cognito-idp create-user-pool \
--region "$AWS_REGION" \
--pool-name "$POOL_NAME" \
--mfa-configuration OFF \
--admin-create-user-config "AllowAdminCreateUserOnly=true" \
--account-recovery-setting 'RecoveryMechanisms=[{Name=admin_only,Priority=1}]' \
--policies "PasswordPolicy={MinimumLength=${PASSWORD_MIN_LENGTH},RequireUppercase=true,RequireLowercase=true,RequireNumbers=true,RequireSymbols=true,TemporaryPasswordValidityDays=1}" \
--schema \
'Name=environment_id,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=1,MaxLength=256}' \
'Name=description,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=2048}' \
'Name=created_by,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=256}' \
'Name=is_service_account,AttributeDataType=String,Mutable=true,DeveloperOnlyAttribute=false,StringAttributeConstraints={MinLength=0,MaxLength=8}' \
--query "UserPool.Id" --output text)
if [ -z "$USER_POOL_ID" ] || [ "$USER_POOL_ID" = "None" ]; then
log "ERROR: create-user-pool did not return a pool id."
exit 1
fi
log "Pool created: $USER_POOL_ID"
ISSUER_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}"
JWKS_URL="${ISSUER_URL}/.well-known/jwks.json"
# ---------------------------------------------------------------------------
# 2. Create the single app client for this pool.
#
# Notes:
# - --no-generate-secret : callers authenticate with user/pass,
# not a client secret.
# - --explicit-auth-flows : USER_PASSWORD_AUTH for the initial
# InitiateAuth, REFRESH_TOKEN_AUTH so the
# caller can refresh.
# - --enable-token-revocation : lets disable+global-signout invalidate
# refresh tokens immediately.
# - --prevent-user-existence-errors ENABLED
# : do not leak whether a username exists.
# - --token-validity-units : access/id in minutes, refresh in days.
# ---------------------------------------------------------------------------
log "---"
log "Creating app client '$APP_CLIENT_NAME'..."
APP_CLIENT_OUTPUT=$(aws cognito-idp create-user-pool-client \
--region "$AWS_REGION" \
--user-pool-id "$USER_POOL_ID" \
--client-name "$APP_CLIENT_NAME" \
--no-generate-secret \
--explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--access-token-validity "$ACCESS_TOKEN_VALIDITY_MIN" \
--id-token-validity "$ID_TOKEN_VALIDITY_MIN" \
--refresh-token-validity "$REFRESH_TOKEN_VALIDITY_DAYS" \
--token-validity-units "AccessToken=minutes,IdToken=minutes,RefreshToken=days" \
--enable-token-revocation \
--prevent-user-existence-errors ENABLED)
APP_CLIENT_ID=$(echo "$APP_CLIENT_OUTPUT" | jq -r '.UserPoolClient.ClientId')
if [ -z "$APP_CLIENT_ID" ] || [ "$APP_CLIENT_ID" = "null" ]; then
log "ERROR: create-user-pool-client did not return a client id."
log "Raw output:"
log "$APP_CLIENT_OUTPUT"
exit 1
fi
log "App client created: $APP_CLIENT_ID"
# ---------------------------------------------------------------------------
# 3. Sanity-check the JWKS endpoint. If this fails, the multi-issuer verifier
# in internal/cognitoauth/token.go will not be able to validate tokens from
# this pool. Failure here is fatal because nothing downstream will work.
# ---------------------------------------------------------------------------
log "---"
log "Checking JWKS endpoint $JWKS_URL ..."
JWKS_HTTP_CODE=$(curl -s -o /tmp/svc-pool-jwks.json -w "%{http_code}" "$JWKS_URL")
if [ "$JWKS_HTTP_CODE" != "200" ]; then
log "ERROR: JWKS endpoint returned HTTP $JWKS_HTTP_CODE"
exit 1
fi
JWKS_KEY_COUNT=$(jq '.keys | length' /tmp/svc-pool-jwks.json)
log "JWKS reachable (HTTP 200); key count: $JWKS_KEY_COUNT"
# ---------------------------------------------------------------------------
# 4. Summary block. Copy these values into the test environment's settings
# when wiring queryAPI to the new pool:
#
# COGNITO_SVC_USER_POOL_ID=<user pool id>
# COGNITO_SVC_JWKS_URL=<jwks url>
# COGNITO_SVC_APP_CLIENT_ID=<app client id>
# ---------------------------------------------------------------------------
log "==================================================================="
log "Service-accounts pool provisioned"
log "==================================================================="
log "AWS region : $AWS_REGION"
log "User pool name : $POOL_NAME"
log "User pool id : $USER_POOL_ID"
log "Issuer URL : $ISSUER_URL"
log "JWKS URL : $JWKS_URL"
log "App client name : $APP_CLIENT_NAME"
log "App client id : $APP_CLIENT_ID"
log "Access token TTL : ${ACCESS_TOKEN_VALIDITY_MIN} minutes"
log "Id token TTL : ${ID_TOKEN_VALIDITY_MIN} minutes"
log "Refresh token TTL : ${REFRESH_TOKEN_VALIDITY_DAYS} days"
log "Password min length : ${PASSWORD_MIN_LENGTH}"
log "MFA : OFF"
log "Self-signup : DISABLED (admin-create-user only)"
log "Account recovery : admin_only"
log "Token revocation : ENABLED"
log "==================================================================="
log ""
log "Next step: create a test service-account user with"
log ""
log " aws cognito-idp admin-create-user \\"
log " --region $AWS_REGION \\"
log " --user-pool-id $USER_POOL_ID \\"
log " --username svc-test-1 \\"
log " --user-attributes Name=email,Value=svc-test-1@svcacct.local \\"
log " --message-action SUPPRESS"
log ""
log " aws cognito-idp admin-set-user-password \\"
log " --region $AWS_REGION \\"
log " --user-pool-id $USER_POOL_ID \\"
log " --username svc-test-1 \\"
log " --password '<32-char-strong-password>' \\"
log " --permanent"
log ""
log " aws cognito-idp initiate-auth \\"
log " --region $AWS_REGION \\"
log " --client-id $APP_CLIENT_ID \\"
log " --auth-flow USER_PASSWORD_AUTH \\"
log " --auth-parameters USERNAME=svc-test-1,PASSWORD='<password>'"
log ""
log "Log of this run saved to: $LOG_FILE"
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
#
# create.test.super.admin.sh
#
# Provision one test service-account user in the pool created by
# create.pool.and.client.sh, set a permanent random password, and run a
# round-trip InitiateAuth so we know the credentials work end-to-end.
#
# Run from AWS CloudShell while authenticated as an admin in the target
# account.
#
# Outputs (to stdout AND to LOG_FILE):
# - Username, email, sub
# - Password (printed ONCE — Cognito stores only the hash)
# - AccessToken, IdToken, RefreshToken from a test InitiateAuth
# - Decoded JWT claims (so you can verify the shape queryAPI will see)
# - Pool-level identifiers echoed back for convenience
#
# Note on "super admin": super_admin is a Permit.io role, not a Cognito
# attribute. This script creates only the Cognito identity. To actually
# grant super_admin, use the printed `sub` as the Permit user key and
# assign the role via Permit.io (console, /admin/users/{sub}/roles, or
# whatever role-assignment path you already use for human users).
#
# Requires: aws cli v2, jq, openssl, python3 (all pre-installed in
# CloudShell).
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Constants from the prior create.pool.and.client.sh run.
# Edit if you re-provisioned the pool.
# ---------------------------------------------------------------------------
AWS_REGION="us-east-2"
USER_POOL_ID="us-east-2_Qom1i9qIG"
APP_CLIENT_ID="6r7chkvjotgs2bsfbd5ibaaft7"
ISSUER_URL="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}"
JWKS_URL="${ISSUER_URL}/.well-known/jwks.json"
# ---------------------------------------------------------------------------
# Test user configuration.
# ---------------------------------------------------------------------------
USERNAME="svc-test-admin-1"
LABEL="$USERNAME"
EMAIL="${LABEL}@svcacct.local"
DESCRIPTION="Test service account for super-admin role validation"
CREATED_BY="cloudshell-admin"
LOG_FILE="create.test.super.admin.log.txt"
> "$LOG_FILE"
log() {
echo "$@" | tee -a "$LOG_FILE"
}
# ---------------------------------------------------------------------------
# 1. Refuse to clobber an existing user. Re-running this script is the most
# likely operator mistake; failing loudly is friendlier than silently
# replacing the password and breaking whatever is using the prior one.
# ---------------------------------------------------------------------------
if aws cognito-idp admin-get-user \
--region "$AWS_REGION" \
--user-pool-id "$USER_POOL_ID" \
--username "$USERNAME" >/dev/null 2>&1; then
log "ERROR: user '$USERNAME' already exists in pool $USER_POOL_ID."
log "Delete it first or change USERNAME at the top of this script:"
log " aws cognito-idp admin-delete-user \\"
log " --region $AWS_REGION \\"
log " --user-pool-id $USER_POOL_ID \\"
log " --username $USERNAME"
exit 1
fi
# ---------------------------------------------------------------------------
# 2. Generate a 32-char password that satisfies the pool's policy
# (>=32 chars, upper, lower, digit, symbol).
#
# Structure: a guaranteed sample of each required class ("A1a!") plus 28
# random characters from a base64 alphabet (mix of upper/lower/digits).
# Total = 32 chars. Each class is present at least once; the random tail
# adds entropy.
# ---------------------------------------------------------------------------
RAND_TAIL=$(openssl rand -base64 48 | tr -d '/=+\n' | head -c 28)
PASSWORD="A1a!${RAND_TAIL}"
if [ "${#PASSWORD}" -ne 32 ]; then
log "ERROR: generated password is ${#PASSWORD} chars; expected 32."
exit 1
fi
# ---------------------------------------------------------------------------
# 3. AdminCreateUser. Custom attributes match plan §4.5:
#
# name = label (human-friendly identifier)
# email = synthetic <label>@svcacct.local
# email_verified = true (no verification email is deliverable)
# custom:description = free-form text
# custom:created_by = operator identifier
# custom:is_service_account = "true" (distinguishes SAs in Permit/audit)
#
# --message-action SUPPRESS keeps Cognito from trying to email an invite to
# the synthetic address.
# ---------------------------------------------------------------------------
log "Creating Cognito user '$USERNAME' in pool $USER_POOL_ID..."
CREATE_OUTPUT=$(aws cognito-idp admin-create-user \
--region "$AWS_REGION" \
--user-pool-id "$USER_POOL_ID" \
--username "$USERNAME" \
--user-attributes \
Name=email,Value="$EMAIL" \
Name=email_verified,Value=true \
Name=name,Value="$LABEL" \
Name=custom:description,Value="$DESCRIPTION" \
Name=custom:created_by,Value="$CREATED_BY" \
Name=custom:is_service_account,Value=true \
--message-action SUPPRESS \
--output json)
SUB=$(echo "$CREATE_OUTPUT" | jq -r '.User.Attributes[] | select(.Name=="sub") | .Value')
if [ -z "$SUB" ] || [ "$SUB" = "null" ]; then
log "ERROR: could not extract sub from admin-create-user response."
log "Raw response:"
log "$CREATE_OUTPUT"
exit 1
fi
log "User created. sub=$SUB"
# ---------------------------------------------------------------------------
# 4. AdminSetUserPassword --permanent.
#
# Without --permanent, the user lands in FORCE_CHANGE_PASSWORD state and the
# next InitiateAuth returns a NEW_PASSWORD_REQUIRED challenge, which is the
# wrong UX for a non-interactive service account.
# ---------------------------------------------------------------------------
log "Setting permanent password..."
aws cognito-idp admin-set-user-password \
--region "$AWS_REGION" \
--user-pool-id "$USER_POOL_ID" \
--username "$USERNAME" \
--password "$PASSWORD" \
--permanent
# ---------------------------------------------------------------------------
# 5. Round-trip test: InitiateAuth with USER_PASSWORD_AUTH.
#
# If this fails, something is wrong with either the pool's auth flow config
# or the password we just set. Better to find out now than in queryAPI.
# ---------------------------------------------------------------------------
log "Running test InitiateAuth..."
AUTH_OUTPUT=$(aws cognito-idp initiate-auth \
--region "$AWS_REGION" \
--client-id "$APP_CLIENT_ID" \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters "USERNAME=$USERNAME,PASSWORD=$PASSWORD" \
--output json)
ACCESS_TOKEN=$(echo "$AUTH_OUTPUT" | jq -r '.AuthenticationResult.AccessToken')
ID_TOKEN=$(echo "$AUTH_OUTPUT" | jq -r '.AuthenticationResult.IdToken')
REFRESH_TOKEN=$(echo "$AUTH_OUTPUT" | jq -r '.AuthenticationResult.RefreshToken')
EXPIRES_IN=$(echo "$AUTH_OUTPUT" | jq -r '.AuthenticationResult.ExpiresIn')
if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
log "ERROR: InitiateAuth did not return an AccessToken."
log "Raw response:"
log "$AUTH_OUTPUT"
exit 1
fi
# Decode the access token payload so the operator can eyeball iss, sub,
# token_use, exp, etc. — the same fields queryAPI's verifier inspects.
decode_jwt() {
python3 - "$1" <<'PY'
import sys, base64, json
tok = sys.argv[1]
payload = tok.split('.')[1]
payload += '=' * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY
}
ACCESS_CLAIMS=$(decode_jwt "$ACCESS_TOKEN")
ID_CLAIMS=$(decode_jwt "$ID_TOKEN")
# ---------------------------------------------------------------------------
# 6. Summary. Everything below this point goes to both stdout and LOG_FILE.
# ---------------------------------------------------------------------------
log "==================================================================="
log "Test service-account user created"
log "==================================================================="
log "Pool-level identifiers (from prior provisioning):"
log " AWS region : $AWS_REGION"
log " User pool id : $USER_POOL_ID"
log " App client id : $APP_CLIENT_ID"
log " Issuer URL : $ISSUER_URL"
log " JWKS URL : $JWKS_URL"
log ""
log "User credentials (record these now — Cognito stores only the hash):"
log " Username : $USERNAME"
log " Email (synthetic) : $EMAIL"
log " Cognito sub : $SUB"
log " Password : $PASSWORD"
log ""
log "Test InitiateAuth result (USER_PASSWORD_AUTH succeeded):"
log " ExpiresIn : $EXPIRES_IN seconds"
log ""
log " AccessToken:"
log "$ACCESS_TOKEN"
log ""
log " IdToken:"
log "$ID_TOKEN"
log ""
log " RefreshToken:"
log "$REFRESH_TOKEN"
log ""
log "Decoded AccessToken claims (queryAPI will see this shape):"
log "$ACCESS_CLAIMS"
log ""
log "Decoded IdToken claims:"
log "$ID_CLAIMS"
log ""
log "==================================================================="
log "queryAPI env vars to set in the test environment:"
log "==================================================================="
log " COGNITO_SVC_USER_POOL_ID=$USER_POOL_ID"
log " COGNITO_SVC_JWKS_URL=$JWKS_URL"
log " COGNITO_SVC_APP_CLIENT_ID=$APP_CLIENT_ID"
log ""
log "==================================================================="
log "To make this user a super_admin:"
log "==================================================================="
log "super_admin is a Permit.io role, not a Cognito attribute. Use the"
log "sub above ($SUB) as the Permit user key, then assign the role via"
log "your existing role-management path (Permit.io console or the"
log "/admin/users/{sub}/roles endpoint once queryAPI is wired for the"
log "second pool)."
log ""
log "==================================================================="
log "Smoke test from any host (substitute queryAPI URL):"
log "==================================================================="
log " curl https://<queryAPI-host>/clients \\"
log " -H 'Authorization: Bearer <AccessToken above>'"
log ""
log "Re-fetch a fresh token at any time with:"
log " aws cognito-idp initiate-auth \\"
log " --region $AWS_REGION \\"
log " --client-id $APP_CLIENT_ID \\"
log " --auth-flow USER_PASSWORD_AUTH \\"
log " --auth-parameters USERNAME=$USERNAME,PASSWORD='<password above>'"
log ""
log "Log of this run saved to: $LOG_FILE"
@@ -0,0 +1,81 @@
# service account related
This is the top directory of scripts for creation of cognito assets that are needed for the
<project root>/plans/api.key.support.cognito.two-pool.plan.3.claude.md
These include script for creation of cognito user pools , service clients and serice accounts in cmd/auth_related/service.accounts/creation.scripts
And test scripts that verify that a given service account is working in cmd/auth_related/service.accounts/test.scripts
See ./creation.scripts/log.txt for actual credentials for the test accounts and the congnito ids
# test user information from test run
Here is some output from the test run with actual credentials and output that can be used for future testing.
========================================================================
Configuration
========================================================================
AWS region : us-east-2
User pool id : us-east-2_Qom1i9qIG
App client id: 6r7chkvjotgs2bsfbd5ibaaft7
Issuer URL : https://cognito-idp.us-east-2.amazonaws.com/us-east-2_Qom1i9qIG
JWKS URL : https://cognito-idp.us-east-2.amazonaws.com/us-east-2_Qom1i9qIG/.well-known/jwks.json
Username : svc-test-admin-1
========================================================================
Step 1: Fetch JWKS
========================================================================
GET https://cognito-idp.us-east-2.amazonaws.com/us-east-2_Qom1i9qIG/.well-known/jwks.json
2 key(s) returned.
========================================================================
Step 2: InitiateAuth (USER_PASSWORD_AUTH)
========================================================================
TokenType : Bearer
ExpiresIn : 3600 seconds
...
========================================================================
Step 3: AccessToken claims (decoded, NOT signature-verified)
========================================================================
{
"auth_time": 1779223801,
"client_id": "6r7chkvjotgs2bsfbd5ibaaft7",
"event_id": "1a93fdd3-fa5b-4719-ab61-0a7c0a9d545e",
"exp": 1779227401,
"iat": 1779223801,
"iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_Qom1i9qIG",
"jti": "8815a7db-0626-4754-9afd-3aa047ba606d",
"origin_jti": "d477a358-64c0-452d-ac33-d44bde540e29",
"scope": "aws.cognito.signin.user.admin",
"sub": "c16b0520-80e1-7076-eeb2-1e520d81e781",
"token_use": "access",
"username": "svc-test-admin-1"
}
iss matches expected issuer: True
token_use: access (expected 'access')
========================================================================
Step 4: IdToken claims (decoded)
========================================================================
{
"aud": "6r7chkvjotgs2bsfbd5ibaaft7",
"auth_time": 1779223801,
"cognito:username": "svc-test-admin-1",
"custom:created_by": "cloudshell-admin",
"custom:description": "Test service account for super-admin role validation",
"custom:is_service_account": "true",
"email": "svc-test-admin-1@svcacct.local",
"email_verified": true,
"event_id": "1a93fdd3-fa5b-4719-ab61-0a7c0a9d545e",
"exp": 1779227401,
"iat": 1779223801,
"iss": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_Qom1i9qIG",
"jti": "8587f064-11f0-46b6-a7ab-ed9d899f2cd3",
"name": "svc-test-admin-1",
"origin_jti": "d477a358-64c0-452d-ac33-d44bde540e29",
"sub": "c16b0520-80e1-7076-eeb2-1e520d81e781",
"token_use": "id"
}
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
test.service.account.auth.py
Local-dev smoke test for the service-accounts Cognito user pool created
by plans/api.key.support/create.pool.and.client.sh and the test user
created by plans/api.key.support/create.test.super.admin.sh.
Run this from any machine with network access to AWS — including a
laptop. No AWS IAM credentials are required: Cognito's InitiateAuth is
a public, unauthenticated endpoint. The app client id plus the user's
own username/password are the only credentials that flow over the wire.
ZERO external dependencies
--------------------------
The script uses only Python's standard library (urllib, json, base64).
No boto3, no requests, no pip install required. This is deliberate: it
must run on any developer machine without modifying the host's Python
environment.
How Cognito's API is called without boto3
-----------------------------------------
Cognito Identity Provider exposes a JSON-over-HTTPS API. Every call is
a POST to https://cognito-idp.<region>.amazonaws.com/ with:
Content-Type: application/x-amz-json-1.1
X-Amz-Target: AWSCognitoIdentityProviderService.<OperationName>
and a JSON body matching the operation's input shape. For unauthenticated
calls like InitiateAuth, no AWS signature is required — the request is
plain HTTPS. boto3 is a convenience wrapper around exactly this pattern.
What this script does (top to bottom)
-------------------------------------
Step 1. GET the pool's JWKS endpoint. Proves the URL queryAPI will
download keys from is reachable from this machine.
Step 2. POST InitiateAuth with AuthFlow=USER_PASSWORD_AUTH. Returns
AccessToken, IdToken, RefreshToken, ExpiresIn.
Step 3. Decode the AccessToken payload (base64url of the middle JWT
segment) and pretty-print the claims. This is the exact shape
queryAPI's verifier will see.
Step 4. Decode the IdToken payload likewise. queryAPI's
ExtractUserInfo reads identity claims (email,
cognito:username, custom attributes) from this token.
Step 5. POST InitiateAuth again with AuthFlow=REFRESH_TOKEN_AUTH using
the refresh token from step 2. Proves the refresh path works
so long-running callers can stay authenticated without
re-sending the password.
Step 6. Print full token strings at the end so they can be copied
into a curl/Postman call against queryAPI once the multi-
issuer verifier is wired up.
What this verifies
------------------
- USER_PASSWORD_AUTH and REFRESH_TOKEN_AUTH are enabled on the app
client.
- The test user's password is permanent (no NEW_PASSWORD_REQUIRED
challenge comes back).
- The JWKS endpoint is reachable from this network.
- The JWT claim shape matches what queryAPI's middleware reads
(`sub`, `iss`, `token_use`, `exp`, and the custom attributes set
during AdminCreateUser).
What this does NOT do
---------------------
- Does not call queryAPI. Once queryAPI is configured for the second
pool, paste the AccessToken into:
curl https://<queryAPI-host>/<endpoint> \\
-H "Authorization: Bearer <AccessToken>"
- Does not cryptographically verify the JWT signature against the
JWKS. queryAPI does that. Doing it here would require a crypto
library (PyJWT[crypto] or cryptography), which is exactly the kind
of external dependency this script avoids.
Requirements
------------
python 3.8+ (stdlib only)
Usage
-----
python3 plans/api.key.support/test.service.account.auth.py
The configuration block immediately below is the only thing to edit.
All values come from the two earlier provisioning scripts' outputs.
"""
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict
# ---------------------------------------------------------------------------
# Configuration is read from environment variables (see repo .env.example).
# Required env vars:
# SVC_TEST_USER_POOL_ID, SVC_TEST_APP_CLIENT_ID,
# SVC_TEST_USERNAME, SVC_TEST_PASSWORD
# Optional: AWS_REGION (defaults to us-east-2).
#
# A .env file at the repo root is auto-loaded if present. Real values
# MUST NOT be committed; the repo's .gitignore covers .env.
# ---------------------------------------------------------------------------
HTTP_TIMEOUT = 10
def _load_dotenv() -> None:
"""Populate os.environ from the repo-root .env without external deps."""
here = Path(__file__).resolve()
for parent in [here.parent, *here.parents]:
candidate = parent / ".env"
if candidate.is_file():
for raw in candidate.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
return
def _required(name: str) -> str:
v = os.environ.get(name, "").strip()
if not v:
print(f"FAIL: required env var {name} is unset. See .env.example.")
sys.exit(2)
return v
_load_dotenv()
AWS_REGION = os.environ.get("AWS_REGION", "us-east-2")
USER_POOL_ID = _required("SVC_TEST_USER_POOL_ID")
APP_CLIENT_ID = _required("SVC_TEST_APP_CLIENT_ID")
USERNAME = _required("SVC_TEST_USERNAME")
PASSWORD = _required("SVC_TEST_PASSWORD")
ISSUER_URL = f"https://cognito-idp.{AWS_REGION}.amazonaws.com/{USER_POOL_ID}"
JWKS_URL = f"{ISSUER_URL}/.well-known/jwks.json"
COGNITO_IDP_URL = f"https://cognito-idp.{AWS_REGION}.amazonaws.com/"
# ---------------------------------------------------------------------------
def cognito_call(operation: str, body: Dict[str, Any]) -> Dict[str, Any]:
"""POST a JSON request to Cognito Identity Provider and parse the response.
AWS Cognito's JSON-over-HTTPS protocol:
- Single endpoint per region (https://cognito-idp.<region>.amazonaws.com/).
- Operation selected via the X-Amz-Target header, value of the form
"AWSCognitoIdentityProviderService.<OperationName>".
- Content-Type is "application/x-amz-json-1.1".
- Body is the operation's input as JSON.
Unauthenticated operations (like InitiateAuth) do not require a SigV4
signature, so we don't need any AWS credentials.
On HTTP errors (HTTPError raised by urllib for any non-2xx response),
Cognito returns a JSON body shaped like:
{"__type": "NotAuthorizedException", "message": "Incorrect username or password."}
We surface that as a RuntimeError so callers see the real reason.
"""
payload = json.dumps(body).encode("utf-8")
request = urllib.request.Request(
COGNITO_IDP_URL,
data=payload,
method="POST",
headers={
"Content-Type": "application/x-amz-json-1.1",
"X-Amz-Target": f"AWSCognitoIdentityProviderService.{operation}",
},
)
try:
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response:
return json.loads(response.read())
except urllib.error.HTTPError as e:
body_text = e.read().decode("utf-8", errors="replace")
# Cognito returns error details in the body even on failure responses.
try:
err = json.loads(body_text)
err_type = err.get("__type", "UnknownError")
err_msg = err.get("message", body_text)
raise RuntimeError(
f"Cognito {operation} failed: HTTP {e.code} {err_type}: {err_msg}"
) from None
except json.JSONDecodeError:
raise RuntimeError(
f"Cognito {operation} failed: HTTP {e.code} {body_text}"
) from None
def fetch_jwks(url: str) -> Dict[str, Any]:
"""GET the pool's JWKS document. Returns the parsed JSON body."""
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as response:
return json.loads(response.read())
def decode_jwt_payload(token: str) -> Dict[str, Any]:
"""Base64url-decode and JSON-parse the payload segment of a JWT.
A JWT is three base64url-encoded segments separated by dots:
header.payload.signature. We only need the payload to inspect the
claims; signature verification happens in queryAPI, not here. The
`+ "=" * (-len(s) % 4)` trick re-adds the base64 padding that JWT
libraries strip when emitting the token.
"""
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
return json.loads(payload_bytes)
def pretty(d: Any) -> str:
"""Indented JSON dump with stable key ordering for diffable output."""
return json.dumps(d, indent=2, sort_keys=True, default=str)
def section(title: str) -> None:
print()
print("=" * 72)
print(title)
print("=" * 72)
def main() -> int:
section("Configuration")
print(f" AWS region : {AWS_REGION}")
print(f" User pool id : {USER_POOL_ID}")
print(f" App client id: {APP_CLIENT_ID}")
print(f" Issuer URL : {ISSUER_URL}")
print(f" JWKS URL : {JWKS_URL}")
print(f" Username : {USERNAME}")
# -----------------------------------------------------------------------
# Step 1: JWKS reachability.
#
# If this fails, queryAPI's startup will fail too (it fetches the same
# URL to build its keyset cache). Better to catch it now.
# -----------------------------------------------------------------------
section("Step 1: Fetch JWKS")
print(f"GET {JWKS_URL}")
try:
jwks = fetch_jwks(JWKS_URL)
except (urllib.error.URLError, urllib.error.HTTPError) as e:
print(f"FAIL: could not reach JWKS endpoint: {e}")
return 1
keys = jwks.get("keys", [])
print(f" {len(keys)} key(s) returned.")
if not keys:
print("FAIL: JWKS document has no keys.")
return 1
for k in keys:
print(f" kid={k.get('kid')} kty={k.get('kty')} alg={k.get('alg')} use={k.get('use')}")
# -----------------------------------------------------------------------
# Step 2: InitiateAuth with username/password.
#
# AuthFlow=USER_PASSWORD_AUTH sends the password in the body over TLS.
# The app client must have ALLOW_USER_PASSWORD_AUTH in its
# ExplicitAuthFlows (set by create.pool.and.client.sh).
# -----------------------------------------------------------------------
section("Step 2: InitiateAuth (USER_PASSWORD_AUTH)")
try:
auth_resp = cognito_call(
"InitiateAuth",
{
"AuthFlow": "USER_PASSWORD_AUTH",
"ClientId": APP_CLIENT_ID,
"AuthParameters": {
"USERNAME": USERNAME,
"PASSWORD": PASSWORD,
},
},
)
except RuntimeError as e:
print(f"FAIL: {e}")
return 1
# If the user has a forced password change pending (e.g., the
# --permanent flag was missed during AdminSetUserPassword), Cognito
# returns a ChallengeName instead of an AuthenticationResult.
if "AuthenticationResult" not in auth_resp:
print("FAIL: Cognito returned a challenge instead of tokens.")
print(f" ChallengeName: {auth_resp.get('ChallengeName')}")
print(pretty(auth_resp))
return 1
result = auth_resp["AuthenticationResult"]
access_token = result["AccessToken"]
id_token = result["IdToken"]
refresh_token = result["RefreshToken"]
expires_in = result["ExpiresIn"]
token_type = result["TokenType"]
print(f" TokenType : {token_type}")
print(f" ExpiresIn : {expires_in} seconds")
print(f" AccessToken : {access_token[:48]}... ({len(access_token)} chars)")
print(f" IdToken : {id_token[:48]}... ({len(id_token)} chars)")
print(f" RefreshToken : {refresh_token[:48]}... ({len(refresh_token)} chars)")
# -----------------------------------------------------------------------
# Step 3: Decode the AccessToken payload.
#
# queryAPI's verifier (internal/cognitoauth/token.go) reads these
# fields. Look for:
# - iss matches ISSUER_URL above
# - token_use == "access"
# - sub matches the Cognito sub printed by the create script
# - exp is ~ExpiresIn seconds from now
# -----------------------------------------------------------------------
section("Step 3: AccessToken claims (decoded, NOT signature-verified)")
access_claims = decode_jwt_payload(access_token)
print(pretty(access_claims))
iss_match = access_claims.get("iss") == ISSUER_URL
print(f"\n iss matches expected issuer: {iss_match}")
print(f" token_use: {access_claims.get('token_use')} (expected 'access')")
# -----------------------------------------------------------------------
# Step 4: Decode the IdToken payload.
#
# The IdToken carries the identity claims (email, cognito:username,
# custom attributes). queryAPI's ExtractUserInfo reads from here.
# -----------------------------------------------------------------------
section("Step 4: IdToken claims (decoded)")
id_claims = decode_jwt_payload(id_token)
print(pretty(id_claims))
# -----------------------------------------------------------------------
# Step 5: Refresh-token round-trip.
#
# A long-running caller will refresh ~10 min before AccessToken expiry
# rather than re-sending the password every hour. This step proves the
# refresh flow is enabled and works against the user we just created.
# -----------------------------------------------------------------------
section("Step 5: Refresh the access token (REFRESH_TOKEN_AUTH)")
try:
refresh_resp = cognito_call(
"InitiateAuth",
{
"AuthFlow": "REFRESH_TOKEN_AUTH",
"ClientId": APP_CLIENT_ID,
"AuthParameters": {
"REFRESH_TOKEN": refresh_token,
},
},
)
except RuntimeError as e:
print(f"FAIL: {e}")
return 1
if "AuthenticationResult" not in refresh_resp:
print("FAIL: refresh did not return AuthenticationResult.")
print(pretty(refresh_resp))
return 1
new_result = refresh_resp["AuthenticationResult"]
new_access = new_result["AccessToken"]
new_expires = new_result["ExpiresIn"]
print(f" New AccessToken : {new_access[:48]}... ({len(new_access)} chars)")
print(f" New ExpiresIn : {new_expires} seconds")
print(f" (RefreshToken is reused; Cognito does not rotate it on refresh.)")
# -----------------------------------------------------------------------
# Step 6: Full token strings.
#
# Useful for piping into curl when smoke-testing queryAPI:
# curl https://<host>/<endpoint> -H "Authorization: Bearer <token>"
# -----------------------------------------------------------------------
section("Step 6: Full tokens (copy these for queryAPI smoke tests)")
print("AccessToken:")
print(access_token)
print()
print("IdToken:")
print(id_token)
print()
print("RefreshToken:")
print(refresh_token)
section("Result")
print("All steps passed. The service-accounts pool is producing JWTs")
print("that match the shape queryAPI's multi-issuer verifier expects.")
return 0
if __name__ == "__main__":
sys.exit(main())