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:
@@ -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
@@ -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 ./... "$@"
|
||||
+202
@@ -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"
|
||||
+254
@@ -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"
|
||||
}
|
||||
+396
@@ -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())
|
||||
Reference in New Issue
Block a user