├── .dockerignore ├── .gitignore ├── Dockerfile ├── cmd └── awsauthd │ └── awsauthd.go ├── google_group_test.go ├── cloudformation.mk.example ├── Makefile ├── awsauthd.conf.example ├── google_login.go ├── google_group.go ├── policy.go ├── aws.go ├── README.md ├── http.go └── cloudformation.template /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .git 3 | awsauthd.conf 4 | cloudformation.mk -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go template 2 | *.o 3 | *.a 4 | *.so 5 | _obj 6 | _test 7 | *.[568vq] 8 | [568vq].out 9 | *.cgo1.go 10 | *.cgo2.c 11 | _cgo_defun.c 12 | _cgo_gotypes.go 13 | _cgo_export.* 14 | _testmain.go 15 | *.exe 16 | *.test 17 | *.prof 18 | .idea 19 | *.swp 20 | awsauthd.conf 21 | cloudformation.mk 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang 2 | 3 | # Pull in depenedencies we know we'll need so we don't have to re-pull them 4 | # every time the source changes 5 | RUN \ 6 | go get golang.org/x/oauth2 && \ 7 | go get github.com/dgrijalva/jwt-go && \ 8 | go get github.com/crowdmob/goamz/... && \ 9 | true 10 | 11 | ADD . /go/src/github.com/crewjam/awsconsoleauth 12 | WORKDIR /go/src/github.com/crewjam/awsconsoleauth 13 | 14 | # Pull in any missing dependencies 15 | RUN go get ./... 16 | RUN go install ./... 17 | 18 | CMD awsauthd 19 | -------------------------------------------------------------------------------- /cmd/awsauthd/awsauthd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/drone/config" 10 | 11 | "github.com/crewjam/awsconsoleauth" 12 | ) 13 | 14 | func main() { 15 | listenAddress := flag.String("listen", ":8080", "The address the web server should listen on") 16 | configFile := flag.String("config", "", "The path to the configuration file") 17 | flag.Parse() 18 | 19 | config.SetPrefix("AWSAUTHD_") 20 | config.Parse(*configFile) 21 | 22 | if err := awsconsoleauth.Initialize(); err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | fmt.Printf("Listening on %s\n", *listenAddress) 27 | log.Fatal(http.ListenAndServe(*listenAddress, nil)) 28 | } 29 | -------------------------------------------------------------------------------- /google_group_test.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFormatRsaKey(t *testing.T) { 8 | expected := `-----BEGIN RSA PRIVATE KEY----- 9 | FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456 10 | FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456 11 | FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456 12 | -----END RSA PRIVATE KEY-----` 13 | 14 | actual := formatRsaKey(expected) 15 | if actual != expected { 16 | t.Errorf("Normal: expected %q, got %q", expected, actual) 17 | } 18 | 19 | compact := "FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456 FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456 FAKEFAKEABCDEF12345678FAKEFAKEABCDEF12345678FAKEFAKEABCDEF123456" 20 | actual = formatRsaKey(compact) 21 | if actual != expected { 22 | t.Errorf("Compact: expected %q, got %q", expected, actual) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cloudformation.mk.example: -------------------------------------------------------------------------------- 1 | 2 | # The name of the cloudformation stack 3 | STACK_NAME=authproxy 4 | 5 | # The name of the docker image. (If you want to build your own image, change 6 | # this to something you control) 7 | DOCKER_IMAGE=crewjam/awsauthproxy 8 | 9 | # The public DNS name of the service 10 | DNS_NAME=aws.example.com 11 | 12 | # The ARN for the certificate you uploaded (see README.md) 13 | CERTIFICATE_ARN=arn:aws:iam::123456789012:server-certificate/$(DNS_NAME) 14 | 15 | # Which SSH key to use 16 | KEY_PAIR=alice 17 | 18 | # The name of your google apps domain. Only users from this domain are allowed 19 | # to log in. 20 | GOOGLE_DOMAIN=example.com 21 | 22 | # Your Google OAuth client ID and secret. This is used to enable identity 23 | # federation. Get yours from https://console.developers.google.com/ 24 | # (see README.md for details) 25 | GOOGLE_CLIENT_ID=1234-xxxx.apps.googleusercontent.com 26 | GOOGLE_CLIENT_SECRET=yyyy 27 | GOOGLE_SERVICE_EMAIL=1234-adfasdfasdfasdf@developer.gserviceaccount.com 28 | GOOGLE_SERVICE_PRIVATE_KEY="\ 29 | -----BEGIN RSA PRIVATE KEY-----\ 30 | ...\ 31 | -----END RSA PRIVATE KEY-----" 32 | GOOGLE_SERVICE_USER=alice@example.com 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: help build create update put-config 3 | 4 | include cloudformation.mk 5 | 6 | help: 7 | @echo "Available targets:" 8 | @echo "" 9 | @echo " build - build the Docker image and push it to the docker hub" 10 | @echo " create - create a cloudformation stack" 11 | @echo " update - update the cloudformation stack" 12 | 13 | build: 14 | docker build -t $(DOCKER_IMAGE) . 15 | docker tag -f $(DOCKER_IMAGE) $(DOCKER_IMAGE):latest 16 | docker push $(DOCKER_IMAGE):latest 17 | 18 | create: 19 | aws cloudformation create-stack --stack-name $(STACK_NAME) \ 20 | --template-body="$(cat cloudformation.template)" \ 21 | --capabilities=CAPABILITY_IAM \ 22 | --parameters \ 23 | ParameterKey=DnsName,ParameterValue=$(DNS_NAME) \ 24 | ParameterKey=KeyPair,ParameterValue=$(KEY_PAIR) \ 25 | ParameterKey=FrontendSSLCertificateARN,ParameterValue=$(CERTIFICATE_ARN) \ 26 | ParameterKey=GoogleDomain,ParameterValue=$(GOOGLE_DOMAIN) \ 27 | ParameterKey=GoogleClientID,ParameterValue=$(GOOGLE_CLIENT_ID) \ 28 | ParameterKey=GoogleClientSecret,ParameterValue=$(GOOGLE_CLIENT_SECRET) \ 29 | ParameterKey=GoogleServiceEmail,ParameterValue=$(GOOGLE_SERVICE_EMAIL) \ 30 | ParameterKey=GoogleServicePrivateKey,ParameterValue="$(GOOGLE_SERVICE_PRIVATE_KEY)" \ 31 | ParameterKey=GoogleServiceUser,ParameterValue=$(GOOGLE_SERVICE_USER) \ 32 | ParameterKey=DockerImage,ParameterValue=$(DOCKER_IMAGE):latest 33 | 34 | update: 35 | aws cloudformation update-stack --stack-name $(STACK_NAME) \ 36 | --template-body='$(shell cat cloudformation.template)' \ 37 | --capabilities=CAPABILITY_IAM \ 38 | --parameters \ 39 | ParameterKey=DnsName,ParameterValue=$(DNS_NAME) \ 40 | ParameterKey=KeyPair,ParameterValue=$(KEY_PAIR) \ 41 | ParameterKey=FrontendSSLCertificateARN,ParameterValue=$(CERTIFICATE_ARN) \ 42 | ParameterKey=GoogleDomain,ParameterValue=$(GOOGLE_DOMAIN) \ 43 | ParameterKey=GoogleClientID,ParameterValue=$(GOOGLE_CLIENT_ID) \ 44 | ParameterKey=GoogleClientSecret,ParameterValue=$(GOOGLE_CLIENT_SECRET) \ 45 | ParameterKey=GoogleServiceEmail,ParameterValue=$(GOOGLE_SERVICE_EMAIL) \ 46 | ParameterKey=GoogleServicePrivateKey,ParameterValue=$(GOOGLE_SERVICE_PRIVATE_KEY) \ 47 | ParameterKey=GoogleServiceUser,ParameterValue=$(GOOGLE_SERVICE_USER) \ 48 | ParameterKey=DockerImage,UsePreviousValue=true 49 | -------------------------------------------------------------------------------- /awsauthd.conf.example: -------------------------------------------------------------------------------- 1 | # This file specifies the configuration for awsauthd. 2 | # It contains important authorization secrets so you should keep it private. 3 | 4 | # The name of your google apps domain. Only users from this domain are allowed 5 | # to log in. 6 | google_domain = "example.com" 7 | 8 | # Your Google OAuth client ID and secret. This is used to enable identity 9 | # federation. Get yours from https://console.developers.google.com/ 10 | # (see README.md for details) 11 | google_client_id = XXX.apps.googleusercontent.com" 12 | google_client_secret = "XXX" 13 | 14 | # Your Google service account email address and private key. 15 | # Get yours from https://console.developers.google.com/ 16 | # (see README.md for details). 17 | # The .p12 file you downloaded with a trivial passphrase. To get a plaintext 18 | # version of the private key, do this: 19 | # 20 | # openssl pkcs12 -in ~/Downloads/ExampleProject-aaaaaaaaaaaa.p12 -nodes 21 | # 22 | google_service_email = "...@developer.gserviceaccount.com" 23 | google_service_private_key = """\ 24 | -----BEGIN RSA PRIVATE KEY----- 25 | ... 26 | -----END RSA PRIVATE KEY----- 27 | """ 28 | 29 | # The Google service account requires a user to impersonate when checking the 30 | # directory to see which groups a user is in. Specify this user here. This user 31 | # is also used to test the directory service at startup. 32 | google_service_user = "alice@example.com" 33 | 34 | # If true then the web service trusts the X-Forwarded-Proto and X-Forwarded-For 35 | # headers when building URLs and reporting the remote address of a login. You 36 | # should set this to true only if you are running behind a reverse proxy. If you 37 | # use the cloudformation document, this setting is managed for you by setting 38 | # AWSAUTHD_TRUST_X_FORWARDED=true in the environment. 39 | #trust-x-forwarded=false 40 | 41 | # This is the maximum time between the initialization of the Google login prompt 42 | # and when it completes. This controls the expiration of the token we generate 43 | # to pass state through the login process. The default of two minutes is 44 | # probably fine for most people. 45 | #login-timeout=120s 46 | 47 | # Specifies which AWS region to connect to. If awsauthd is running in EC2 it 48 | # detects the region automatically. Otherwise it uses us-east-1. 49 | #aws-region= 50 | 51 | # Specifies the credentials used to call GetFederationToken(). These credentials 52 | # must be regular user credentials, not STS credentials, because 53 | # GetFederationToken doesn't work with STS credentials. The policy applies to 54 | # these credentials form the maximum allowed access for any users that we'll 55 | # authenticate through this service. 56 | # 57 | # If you use the cloudformation document, you can leave these blank. We set 58 | # AWSAUTHD_AWS_ACCESS_KEY_ID and AWSAUTHD_AWS_SECRET_ACCESS_KEY for you in the 59 | # environment. 60 | #aws-access-key-id= 61 | #aws-secret-access-key= 62 | 63 | -------------------------------------------------------------------------------- /google_login.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/dgrijalva/jwt-go" 11 | "github.com/drone/config" 12 | "golang.org/x/oauth2" 13 | "golang.org/x/oauth2/google" 14 | ) 15 | 16 | var googleClientID = config.String("google-client-id", "") 17 | var googleClientSecret = config.String("google-client-secret", "") 18 | var googleDomain = config.String("google-domain", "") 19 | 20 | var googleOauthConfig = &oauth2.Config{ 21 | Scopes: []string{"email"}, 22 | Endpoint: google.Endpoint, 23 | } 24 | 25 | var googleJWTSigningKeys = map[string]interface{}{} 26 | 27 | func updateGoogleJWTSigningKeys() error { 28 | // Fetch the google keys for oauth 29 | googleCertsResponse, err := http.Get("https://www.googleapis.com/oauth2/v1/certs") 30 | if err != nil { 31 | return err 32 | } 33 | if err := json.NewDecoder(googleCertsResponse.Body).Decode(&googleJWTSigningKeys); err != nil { 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | // InitializeGoogleLogin sets up access to the Google login service 40 | func InitializeGoogleLogin() error { 41 | if err := updateGoogleJWTSigningKeys(); err != nil { 42 | return nil 43 | } 44 | 45 | // keep the JWT signing keys up to date by polling once per hour 46 | go func() { 47 | time.Sleep(60 * time.Minute) 48 | for { 49 | if err := updateGoogleJWTSigningKeys(); err != nil { 50 | log.Printf("Google JWT signing keys: %s", err) 51 | time.Sleep(time.Minute) 52 | continue 53 | } 54 | log.Printf("updates google JWT signing keys") 55 | time.Sleep(60 * time.Minute) 56 | } 57 | }() 58 | 59 | // Configure OAuth 60 | googleOauthConfig.ClientID = *googleClientID 61 | googleOauthConfig.ClientSecret = *googleClientSecret 62 | if *googleDomain != "" { 63 | googleOauthConfig.Endpoint.AuthURL += fmt.Sprintf("?hd=%s", *googleDomain) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // GetUserFromGoogleOauthToken returns a user name (email address) from the 70 | // provided ID token which we receive in the OAuth response. This function 71 | // validates that the idToken is signed by a valid JWT public key. 72 | func GetUserFromGoogleOauthToken(idToken string) (string, error) { 73 | token, err := jwt.Parse(idToken, func(t *jwt.Token) (interface{}, error) { 74 | keyString, ok := googleJWTSigningKeys[t.Header["kid"].(string)] 75 | if !ok { 76 | return nil, fmt.Errorf("Unknown key in token") 77 | } 78 | key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(keyString.(string))) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return key, nil 83 | }) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | if *googleDomain != "" { 89 | if token.Claims["hd"].(string) != *googleDomain { 90 | return "", fmt.Errorf("expected domain %s, got domain %s", 91 | *googleDomain, token.Claims["hd"].(string)) 92 | } 93 | } 94 | return token.Claims["email"].(string), nil 95 | } 96 | -------------------------------------------------------------------------------- /google_group.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "strings" 8 | 9 | "github.com/drone/config" 10 | "golang.org/x/oauth2" 11 | "golang.org/x/oauth2/google" 12 | "golang.org/x/oauth2/jwt" 13 | ) 14 | 15 | // GroupsResponse respresents the response we get from the Google Directory API 16 | // to a request for the group membership of a user. 17 | type GroupsResponse struct { 18 | Groups []Group `json:"groups"` 19 | } 20 | 21 | // Group respresents a single group that a user is a member of 22 | type Group struct { 23 | Name string `json:"name"` 24 | } 25 | 26 | const rsaKeyPrefix = "-----BEGIN RSA PRIVATE KEY-----" 27 | 28 | func formatRsaKey(key string) string { 29 | // If this came from the config file it should be formatted right 30 | if !strings.HasPrefix(key, rsaKeyPrefix) { 31 | // Replace spaces in the key with newlines. This makes it 32 | // easier to pass the key in an environment variable 33 | key = strings.Replace(key, " ", "\n", -1) 34 | key = fmt.Sprintf("%s\n%s\n-----END RSA PRIVATE KEY-----", rsaKeyPrefix, key) 35 | } 36 | 37 | return key 38 | } 39 | 40 | var ( 41 | googleServiceEmail = config.String("google-service-email", "") 42 | googleServicePrivateKey = config.String("google-service-private-key", "") 43 | googleServiceUser = config.String("google-service-user", "") 44 | ) 45 | 46 | // InitializeGoogleGroup checks that our Google service account is able to fetch 47 | // group membership for a user (It users the `google-service-user` to test). 48 | func InitializeGoogleGroup() error { 49 | *googleServicePrivateKey = formatRsaKey(*googleServicePrivateKey) 50 | 51 | groups, err := GetUserGroups(*googleServiceUser) 52 | if err != nil { 53 | return fmt.Errorf("Google groups doesn't work: %s", err) 54 | } 55 | fmt.Printf("google groups test: passed (user %s is a member of %#v)\n", 56 | *googleServiceUser, groups) 57 | return nil 58 | } 59 | 60 | // GetUserGroups returns the names of the groups that the specified user is a 61 | // member of. 62 | func GetUserGroups(emailAddress string) ([]string, error) { 63 | conf := jwt.Config{ 64 | Email: *googleServiceEmail, 65 | PrivateKey: []byte(*googleServicePrivateKey), 66 | Scopes: []string{"https://www.googleapis.com/auth/admin.directory.group.readonly"}, 67 | TokenURL: google.JWTTokenURL, 68 | Subject: *googleServiceUser, 69 | } 70 | 71 | client := conf.Client(oauth2.NoContext) 72 | response, err := client.Get(fmt.Sprintf("https://www.googleapis.com/admin/directory/v1/groups?userKey=%s", emailAddress)) 73 | if err != nil { 74 | return nil, fmt.Errorf("fetching groups: %s", err) 75 | } 76 | if response.StatusCode != 200 { 77 | responseBody, err := ioutil.ReadAll(response.Body) 78 | if err != nil { 79 | return nil, fmt.Errorf("fetching groups: returned %s", response.Status) 80 | } 81 | return nil, fmt.Errorf("fetching groups: returned %s: %s", 82 | response.Status, responseBody) 83 | } 84 | 85 | groupsResponse := GroupsResponse{} 86 | if err := json.NewDecoder(response.Body).Decode(&groupsResponse); err != nil { 87 | return nil, fmt.Errorf("parsing groups response: %s", err) 88 | } 89 | 90 | groupNames := []string{} 91 | for _, group := range groupsResponse.Groups { 92 | groupNames = append(groupNames, group.Name) 93 | } 94 | return groupNames, nil 95 | } 96 | -------------------------------------------------------------------------------- /policy.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | // PolicyRecord represents a single policy record. Each record has a `Name` that 9 | // identifies it and a `Policy` which is the text of the policy record. 10 | type PolicyRecord struct { 11 | Name string 12 | Policy string 13 | } 14 | 15 | // PolicyRecords is the ordered list of policy records 16 | var PolicyRecords = []PolicyRecord{ 17 | { 18 | Name: "aws-admin", 19 | Policy: mustMinifyJSON(`{ 20 | "Version": "2012-10-17", 21 | "Statement": [{ 22 | "Sid": "Stmt1", 23 | "Effect": "Allow", 24 | "Action":"*", 25 | "Resource":"*" 26 | }] 27 | }`), 28 | }, 29 | { 30 | Name: "aws-users", 31 | Policy: mustMinifyJSON(`{ 32 | "Version": "2012-10-17", 33 | "Statement": [ 34 | { 35 | "Action": ["iam:List*","iam:Get*","iam:PassRole"], 36 | "Resource": "*", 37 | "Effect": "Allow" 38 | }, 39 | { 40 | "Effect": "Allow", 41 | "NotAction": "iam:*", 42 | "Resource": "*" 43 | } 44 | ] 45 | }`), 46 | }, 47 | { 48 | Name: "aws-read-only", 49 | Policy: mustMinifyJSON(`{ 50 | "Version": "2012-10-17", 51 | "Statement": [ 52 | { 53 | "Action": [ 54 | "autoscaling:Describe*", 55 | "cloudformation:Describe*", 56 | "cloudformation:Get*", 57 | "cloudformation:List*", 58 | "cloudfront:Get*", 59 | "cloudfront:List*", 60 | "cloudtrail:Describe*", 61 | "cloudtrail:Get*", 62 | "cloudwatch:Describe*", 63 | "cloudwatch:Get*", 64 | "cloudwatch:List*", 65 | "dynamodb:Get*", 66 | "dynamodb:BatchGet*", 67 | "dynamodb:Query", 68 | "dynamodb:Scan", 69 | "dynamodb:Describe*", 70 | "dynamodb:List*", 71 | "ec2:Describe*", 72 | "elasticache:Describe*", 73 | "elasticloadbalancing:Describe*", 74 | "elasticmapreduce:Describe*", 75 | "elasticmapreduce:List*", 76 | "elastictranscoder:Read*", 77 | "elastictranscoder:List*", 78 | "iam:List*", 79 | "iam:Get*", 80 | "kinesis:Describe*", 81 | "kinesis:Get*", 82 | "kinesis:List*", 83 | "route53:Get*", 84 | "route53:List*", 85 | "rds:Describe*", 86 | "rds:ListTagsForResource", 87 | "s3:Get*", 88 | "s3:List*", 89 | "sdb:GetAttributes", 90 | "sdb:List*", 91 | "sdb:Select*", 92 | "ses:Get*", 93 | "ses:List*", 94 | "sns:Get*", 95 | "sns:List*", 96 | "sqs:GetQueueAttributes", 97 | "sqs:ListQueues", 98 | "sqs:ReceiveMessage", 99 | "tag:get*" 100 | ], 101 | "Effect": "Allow", 102 | "Resource": "*" 103 | } 104 | ] 105 | }`), 106 | }, 107 | } 108 | 109 | func mustMinifyJSON(input string) string { 110 | output := bytes.NewBuffer(nil) 111 | if err := json.Compact(output, []byte(input)); err != nil { 112 | panic(err) 113 | } 114 | return output.String() 115 | } 116 | 117 | // MapUserAndGroupsToPolicy returns the policy for the specified user and 118 | // groups. 119 | // 120 | // This PolicyRecords list is examined in order. For each record here we check 121 | // if the user is a memeber of the corresponding group. If she is, then the 122 | // associated policy is applied. 123 | // 124 | // If no policy matches, this function returns (nil, nil). 125 | func MapUserAndGroupsToPolicy(user string, groups []string) (*PolicyRecord, error) { 126 | groupNames := map[string]struct{}{} 127 | for _, groupName := range groups { 128 | groupNames[groupName] = struct{}{} 129 | } 130 | 131 | for _, policyRecord := range PolicyRecords { 132 | _, ok := groupNames[policyRecord.Name] 133 | if !ok { 134 | continue 135 | } 136 | 137 | return &policyRecord, nil 138 | } 139 | return nil, nil 140 | } 141 | -------------------------------------------------------------------------------- /aws.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/crowdmob/goamz/aws" 12 | "github.com/crowdmob/goamz/sts" 13 | "github.com/drone/config" 14 | ) 15 | 16 | var awsRegion = config.String("aws-region", "") 17 | 18 | var awsAccessKey = config.String("aws-access-key-id", "") 19 | var awsSecretKey = config.String("aws-secret-access-key", "") 20 | 21 | var awsAuth aws.Auth 22 | 23 | // InitializeAWS sets up access to the AWS Simple Token Service 24 | func InitializeAWS() error { 25 | if *awsRegion == "" { 26 | *awsRegion = aws.InstanceRegion() 27 | if *awsRegion == "unknown" { 28 | *awsRegion = "us-east-1" 29 | } 30 | } 31 | 32 | if *awsAccessKey == "" || *awsSecretKey == "" { 33 | return fmt.Errorf("you must specify aws-access-key-id and " + 34 | "aws-secret-access-key in the config file or " + 35 | "AWSAUTHD_AWS_ACCESS_KEY_ID and AWSAUTHD_AWS_SECRET_ACCESS_KEY in " + 36 | "the environment. These must be regular permanent credentials, not " + 37 | "temporary or instance credentials.") 38 | } 39 | 40 | maybeAWSAuth := aws.Auth{ 41 | AccessKey: *awsAccessKey, 42 | SecretKey: *awsSecretKey, 43 | } 44 | stsConnection := sts.New(maybeAWSAuth, aws.GetRegion(*awsRegion)) 45 | _, err := stsConnection.GetFederationToken("snakeoil", "", 900) 46 | if err != nil { 47 | return fmt.Errorf("Your credentials don't work to call "+ 48 | "GetFederationToken(). You must specify aws-access-key-id and "+ 49 | "aws-secret-access-key in the config file or "+ 50 | "AWSAUTHD_AWS_ACCESS_KEY_ID and AWSAUTHD_AWS_SECRET_ACCESS_KEY in "+ 51 | "the environment. These must be regular permanent credentials, not "+ 52 | "temporary or instance credentials. (err=%s)", err) 53 | } 54 | 55 | // If GetFederationToken worked then we are good to go. 56 | awsAuth = maybeAWSAuth 57 | return nil 58 | } 59 | 60 | // GetCredentials fetches credentials for the specified user and policy. 61 | func GetCredentials(user string, policyString string, 62 | tokenLifetime time.Duration) (*sts.Credentials, error) { 63 | stsConnection := sts.New(awsAuth, aws.GetRegion(*awsRegion)) 64 | getTokenResult, err := stsConnection.GetFederationToken(user, policyString, 65 | int(tokenLifetime.Seconds())) 66 | if err != nil { 67 | return nil, fmt.Errorf("GetFederationToken: %s", err) 68 | } 69 | return &getTokenResult.Credentials, nil 70 | } 71 | 72 | // GetAWSConsoleURL builds a URL that can be used to access the AWS console 73 | // with the provided console. If uri is specified, it is appended to the AWS 74 | // console URL (https://console.aws.amazon.com/) 75 | func GetAWSConsoleURL(credentials *sts.Credentials, uri string) (string, error) { 76 | session := map[string]string{ 77 | "sessionId": credentials.AccessKeyId, 78 | "sessionKey": credentials.SecretAccessKey, 79 | "sessionToken": credentials.SessionToken, 80 | } 81 | sessionString, err := json.Marshal(session) 82 | if err != nil { 83 | panic(err) 84 | } 85 | 86 | federationValues := url.Values{} 87 | federationValues.Add("Action", "getSigninToken") 88 | federationValues.Add("Session", string(sessionString)) 89 | federationURL := "https://signin.aws.amazon.com/federation?" + 90 | federationValues.Encode() 91 | 92 | federationResponse, err := http.Get(federationURL) 93 | if err != nil { 94 | return "", fmt.Errorf("fetching federated signin URL: %s", err) 95 | } 96 | tokenDocument := struct{ SigninToken string }{} 97 | err = json.NewDecoder(federationResponse.Body).Decode(&tokenDocument) 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | values := url.Values{} 103 | values.Add("Action", "login") 104 | values.Add("Destination", 105 | "https://console.aws.amazon.com/"+strings.TrimPrefix(uri, "/")) 106 | values.Add("SigninToken", tokenDocument.SigninToken) 107 | 108 | return "https://signin.aws.amazon.com/federation?" + values.Encode(), nil 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | This is a tool to allow authorized folks to log into an AWS account using 3 | credentials from a Google Apps domain. 4 | 5 | # How It Works 6 | 7 | - Your users navigate to this service. 8 | - We redirect them through the Google login process. 9 | - We check their group membership in the Google directory service to determine 10 | which access policy to apply. 11 | - We generate credentials using the AWS Token service and the GetFederationToken 12 | API. 13 | - We build a URL to the AWS console that contains their temporary credentials 14 | and redirect them there. Alternatively we pass their temporary credentials to 15 | them directly for use with the AWS API. 16 | 17 | Example requests: 18 | 19 | - `https://aws.example.com/` eventually redirects to the root of the console. 20 | - `https://aws.example.com/?uri=/ec2/v2/home?region=us-east-1%23Instances:sort=desc:launchTime` 21 | redirects to the EC2 console view. 22 | - `https://aws.example.com/?view=sh` displays access keys suitable for pasting 23 | into a bash-style shell: 24 | 25 | # expires 2015-03-14 01:01:04 +0000 UTC 26 | export AWS_ACCESS_KEY_ID="ASIAJXXXXXXXXXXXXXXX" 27 | export AWS_SECRET_ACCESS_KEY="uS1aP/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 28 | export AWS_SESSION_TOKEN="AQoD...i6gF" 29 | 30 | You can also try `view=csh` and `view=fish`. 31 | 32 | # Cloudformation 33 | 34 | The cloudformation document creates a load balancer that listens from HTTPS 35 | connections on TCP/443 and proxies them via HTTP to instances in an autoscaling 36 | group of size 1. At boot, the instances run a the `awsauthproxy` docker image 37 | which runs `awsauthd`. 38 | 39 | `awsauthd` loads its configuration from an S3 bucket that is created by 40 | the cloudformation document. The instance profile allows it to access only this 41 | bucket and nothing else. 42 | 43 | The configuration specifies a new set of credentials that are used to execute 44 | the GetFederationToken() API call. These credentials have a policy applied to 45 | them that explicitly disallows reading the configuration bucket. If the 46 | configuration bucket were not protected, the user could access the 47 | federation secrets, which would allow them to exceed their authorized access. 48 | 49 | # Setup 50 | 51 | 1. Get a Google OAuth Client ID and Secret. This is used by the web application 52 | to authorize your users. 53 | 54 | - Navigate to https://console.developers.google.com/ 55 | - Click "Create Project" 56 | - Navigate to "APIs & Auth" and then "Credentials" 57 | - Click "Create Client ID" 58 | - Select "Web Application" and set up the consent screen. 59 | - Under authorized javascript origins, enter the name of your server, i.e. 60 | `https://aws.example.com` 61 | - Under "AUTHORIZED REDIRECT URIS" choose `https://aws.example.com/oauth2callback` 62 | - Click "Create client ID". 63 | - record your client ID and client secret. 64 | 65 | 2. Get a Google Service Account. This is used by the application to determine 66 | which groups the user is in. 67 | 68 | - Navigate to https://console.developers.google.com/ 69 | - Navigate to your project 70 | - Click "Create Client ID" 71 | - Select "Service Account" 72 | - Note the email address created. 73 | - Decrypt the certificate that gets downloaded: 74 | 75 | openssl pkcs12 -in ~/Downloads/My\ Project-afcee0fea02c.p12 -nodes 76 | 77 | Extract the private key part. 78 | 79 | 3. Authorize your new google service account. Follow the 80 | [directions here](https://developers.google.com/accounts/docs/OAuth2ServiceAccount#delegatingauthority) 81 | to authorize your new service account to access the scope 82 | `https://www.googleapis.com/auth/admin.directory.group.readonly`. 83 | 84 | 4. Get an SSL certificate for your domain and upload it to the AWS IAM console. 85 | Note the ARN for your new certificate. 86 | 87 | aws iam upload-server-certificate --server-certificate-name aws.example.com \ 88 | --certificate-body file://ssl.crt \ 89 | --private-key file://ssl.key \ 90 | --certificate-chain file://intermediate.crt 91 | 92 | 5. Build a configuration file from the cloudformation.mk.template filling in all 93 | your secrets 94 | 95 | cp cloudformation.mk.example cloudformation.mk 96 | vi cloudformation.mk 97 | 98 | 6. Create the cloudformation stack described by cloudformation.template. You can 99 | use the provided Makefile, if you you'll need to customize it a little: 100 | 101 | make create 102 | 103 | Note: the Makefile assumes you have the AWS CLI installed. 104 | 105 | 7. After a few moments you should be able to upload your config to the S3 106 | data bucket. 107 | 108 | make put-config 109 | 110 | # Limitations 111 | 112 | - The Google groups and the AWS policy mappings are currently hard coded. 113 | 114 | - The size of policy document passed to GetFederationToken() is fairly limited. 115 | I had to remove stuff from the default ReadOnlyAccess policy to make it fit. 116 | 117 | - We don't currently have a way to restrict access to the service launch 118 | configuration, which exposes the root GetFederationToken() credentials. XXX 119 | 120 | - All errors are reported to users in exactly the same way, by returning 121 | *400 Bad Request*. This has the benefit of preventing any leakage to 122 | unauthorized users but is a little unfriendly. After carefully considering the 123 | implications, we might want errors that are a little friendlier. 124 | 125 | - TODO: google rotates the key the used to sign the JWT, so we get something like 126 | ``2015/03/29 17:04:20 failed to parse google id_token: Unknown key in token`` 127 | workaround is to restart. 128 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package awsconsoleauth 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | "time" 10 | 11 | "github.com/crowdmob/goamz/sts" 12 | "github.com/dgrijalva/jwt-go" 13 | "github.com/drone/config" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var trustXForwarded = config.Bool("trust-x-forwarded", false) 18 | 19 | var loginTimeout = config.Duration("google-login-timeout", time.Second*120) 20 | 21 | // We reuse the Google client secret as the web secret. 22 | var secret = googleClientSecret 23 | 24 | // getOriginUrl returns the HTTP origin string (i.e. 25 | // https://alice.example.com, or http://localhost:8000) 26 | func getOriginURL(r *http.Request) string { 27 | scheme := "https" 28 | if r.TLS == nil { 29 | scheme = "http" 30 | } 31 | if *trustXForwarded { 32 | scheme = r.Header.Get("X-Forwarded-Proto") 33 | } 34 | return fmt.Sprintf("%s://%s", scheme, r.Host) 35 | } 36 | 37 | func getRemoteAddress(r *http.Request) string { 38 | remoteAddr := r.RemoteAddr 39 | if *trustXForwarded { 40 | forwardedFor := strings.Split(r.Header.Get("X-Forwarded-For"), ",") 41 | remoteAddr = strings.TrimSpace(forwardedFor[len(forwardedFor)-1]) 42 | } 43 | return remoteAddr 44 | } 45 | 46 | func newTokenFromRefreshToken(refreshToken string) (*oauth2.Token, error) { 47 | token := new(oauth2.Token) 48 | token.RefreshToken = refreshToken 49 | token.Expiry = time.Now() 50 | 51 | // TokenSource will refresh the token if needed (which is likely in this 52 | // use case) 53 | oauthConfig := *googleOauthConfig 54 | ts := oauthConfig.TokenSource(oauth2.NoContext, token) 55 | 56 | return ts.Token() 57 | } 58 | 59 | // GetRoot handles requests for '/' by redirecting to the Google OAuth URL. 60 | // 61 | // Any query string arguments are passed securely through the OAuth flow to 62 | // /oauth2callback 63 | func GetRoot(w http.ResponseWriter, r *http.Request) { 64 | // If refresh_token is provided, we can use it to get a new ID token to 65 | // identify the user, avoiding the whole OAuth flow and allowing for automation. 66 | refreshToken := r.FormValue("refresh_token") 67 | if refreshToken != "" { 68 | token, err := newTokenFromRefreshToken(refreshToken) 69 | if err == nil { 70 | FetchCredentialsForToken(w, r, token, r.URL.RawQuery) 71 | return 72 | } 73 | // Otherwise we just fallback to the default flow below 74 | } 75 | 76 | state, err := generateState(r) 77 | if err != nil { 78 | log.Printf("ERROR: cannot generate token: %s", err) 79 | http.Error(w, "Authentication failed", 500) 80 | return 81 | } 82 | 83 | oauthConfig := *googleOauthConfig 84 | oauthConfig.RedirectURL = fmt.Sprintf("%s/oauth2callback", getOriginURL(r)) 85 | url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) 86 | http.Redirect(w, r, url, http.StatusFound) 87 | } 88 | 89 | // generateState builds a JWT that we can use as /state/ across the oauth 90 | // request to mitigate CSRF. When handling the callback we use validateState() 91 | // to make sure that the callback request corresponds to a valid request we 92 | // emitted. 93 | func generateState(r *http.Request) (string, error) { 94 | token := jwt.New(jwt.GetSigningMethod("HS256")) 95 | token.Claims["ua"] = r.Header.Get("User-Agent") 96 | token.Claims["ra"] = getRemoteAddress(r) 97 | token.Claims["exp"] = time.Now().Add(*loginTimeout).Unix() 98 | token.Claims["query"] = r.URL.RawQuery 99 | state, err := token.SignedString([]byte(*secret)) 100 | return state, err 101 | } 102 | 103 | // valididateState checks that the state parameter is valid and returns nil if 104 | // so, otherwise it returns a non-nill error value. (Do not show the returned 105 | // error to the user, it might contain security-sensitive information) 106 | func valididateState(r *http.Request, state string) (string, error) { 107 | token, err := jwt.Parse(state, func(t *jwt.Token) (interface{}, error) { 108 | return []byte(*secret), nil 109 | }) 110 | if err != nil { 111 | return "", err 112 | } 113 | 114 | if !token.Valid { 115 | // TODO(ross): this branch is never executed, I think because if the 116 | // token is invalid `err` is never nil from Parse() 117 | return "", fmt.Errorf("Invalid Token") 118 | } 119 | 120 | if token.Claims["ra"].(string) != getRemoteAddress(r) { 121 | return "", fmt.Errorf("Wrong remote address. Expected %#v, got %#v", 122 | token.Claims["ra"].(string), getRemoteAddress(r)) 123 | } 124 | 125 | if token.Claims["ua"].(string) != r.Header.Get("User-Agent") { 126 | return "", fmt.Errorf("Wrong user agent. Expected %#v, got %#v", 127 | token.Claims["ua"].(string), r.Header.Get("User-Agent")) 128 | } 129 | 130 | return token.Claims["query"].(string), nil 131 | } 132 | 133 | // GetCallback handles requests for '/oauth2callback' by validating the oauth 134 | // response, determining the user's group membership, determining the user's 135 | // AWS policy, fetching credentials and (optionally) redirecting to the console. 136 | // 137 | // The returned document is controlled by the `view` argument passed to the 138 | // root URL. 139 | // 140 | // - if view=sh is specified, then a bash-compatible script is returned with the 141 | // credentials 142 | // - if view=csh is specified, then a csh-compatible script is returned with 143 | // the credentials 144 | // - if view=fish is specified, then a fish-compatible script is returned with 145 | // the credentials 146 | // - otherwise we redirect to the AWS Console. If `uri` is specified it is 147 | // appended to the end of the aws console url. 148 | // 149 | // For example: 150 | // 151 | // - https://aws.example.com/?view=sh -> returns a bash script 152 | // 153 | // - https://aws.example.com/?uri=/s3/home -> redirects to https://console.aws.amazon.com/s3/home 154 | // 155 | func GetCallback(w http.ResponseWriter, r *http.Request) { 156 | oauthConfig := *googleOauthConfig 157 | oauthConfig.RedirectURL = fmt.Sprintf("%s/oauth2callback", getOriginURL(r)) 158 | 159 | token, err := oauthConfig.Exchange(oauth2.NoContext, r.FormValue("code")) 160 | if err != nil { 161 | log.Printf("oauth exchange failed: %s", err) 162 | http.Error(w, "Bad Request", http.StatusBadRequest) 163 | return 164 | } 165 | 166 | rawQuery, err := valididateState(r, r.FormValue("state")) 167 | if err != nil { 168 | log.Printf("ERROR: state: %s", err) 169 | http.Error(w, "Bad Request", http.StatusBadRequest) 170 | return 171 | } 172 | 173 | FetchCredentialsForToken(w, r, token, rawQuery) 174 | } 175 | 176 | // Utility method which gets the AWS credentials for the given OAuth token 177 | func FetchCredentialsForToken(w http.ResponseWriter, r *http.Request, 178 | token *oauth2.Token, rawQuery string) { 179 | 180 | user, err := GetUserFromGoogleOauthToken(token.Extra("id_token").(string)) 181 | if err != nil { 182 | log.Printf("failed to parse google id_token: %s", err) 183 | http.Error(w, "Bad Request", http.StatusBadRequest) 184 | return 185 | } 186 | 187 | groups, err := GetUserGroups(user) 188 | if err != nil { 189 | log.Printf("failed to fetch google group membership for %s: %s", user, err) 190 | http.Error(w, "Bad Request", http.StatusBadRequest) 191 | return 192 | } 193 | 194 | policy, err := MapUserAndGroupsToPolicy(user, groups) 195 | if err != nil { 196 | log.Printf("failed to determine policy for %s: %s", user, err) 197 | http.Error(w, "Bad Request", http.StatusBadRequest) 198 | return 199 | } 200 | 201 | if policy == nil { 202 | log.Printf("no matching policy for %s", user) 203 | http.Error(w, "Bad Request", http.StatusBadRequest) 204 | return 205 | } 206 | 207 | credentials, err := GetCredentials(user, policy.Policy, time.Second*43200) 208 | if err != nil { 209 | log.Printf("failed to get credentials for %s: %s", user, err) 210 | http.Error(w, "Bad Request", http.StatusBadRequest) 211 | return 212 | } 213 | 214 | query, err := url.ParseQuery(rawQuery) 215 | if err != nil { 216 | log.Printf("ERROR: parse query: %s", err) 217 | http.Error(w, "Bad Request", http.StatusBadRequest) 218 | return 219 | } 220 | 221 | fmt.Printf("login %s from %s with policy %s key %s\n", user, getRemoteAddress(r), 222 | policy.Name, credentials.AccessKeyId) 223 | 224 | RespondWithCredentials(w, r, credentials, query, token) 225 | } 226 | 227 | func formatEnvVariableForShell(name, value, shell string) string { 228 | switch shell { 229 | case "csh": 230 | return fmt.Sprintf("setenv %s \"%s\"\n", name, value) 231 | case "fish": 232 | return fmt.Sprintf("set -x %s \"%s\"\n", name, value) 233 | default: 234 | return fmt.Sprintf("export %s=\"%s\"\n", name, value) 235 | } 236 | } 237 | 238 | // RespondWithCredentials response to the oauth callback request based on the 239 | // query parameters and the specified credentials. 240 | func RespondWithCredentials(w http.ResponseWriter, r *http.Request, 241 | credentials *sts.Credentials, query url.Values, token *oauth2.Token) { 242 | 243 | view := query.Get("view") 244 | if query.Get("action") == "key" { 245 | view = "sh" 246 | } 247 | 248 | if view == "sh" || view == "csh" || view == "fish" { 249 | w.Header().Set("Content-type", "text-plain") 250 | fmt.Fprintf(w, "# expires %s\n", credentials.Expiration) 251 | fmt.Fprint(w, formatEnvVariableForShell("AWS_ACCESS_KEY_ID", credentials.AccessKeyId, view)) 252 | fmt.Fprint(w, formatEnvVariableForShell("AWS_SECRET_ACCESS_KEY", credentials.SecretAccessKey, view)) 253 | fmt.Fprint(w, formatEnvVariableForShell("AWS_SESSION_TOKEN", credentials.SessionToken, view)) 254 | if token.RefreshToken != "" { 255 | fmt.Fprint(w, "# add this as a refresh_token param in the future to avoid the OAuth flow\n") 256 | fmt.Fprint(w, formatEnvVariableForShell("OAUTH_REFRESH_TOKEN", token.RefreshToken, view)) 257 | } 258 | return 259 | } 260 | 261 | redirectURL, err := GetAWSConsoleURL(credentials, query.Get("uri")) 262 | if err != nil { 263 | log.Printf("ERROR: %s", err) 264 | http.Error(w, "Bad Request", http.StatusBadRequest) 265 | return 266 | } 267 | http.Redirect(w, r, redirectURL, http.StatusFound) 268 | } 269 | 270 | // Initialize sets up the web server and binds the URI patterns for the 271 | // authorization service. 272 | func Initialize() error { 273 | if err := InitializeGoogleGroup(); err != nil { 274 | return fmt.Errorf("InitializeGoogleGroup: %s", err) 275 | } 276 | 277 | if err := InitializeGoogleLogin(); err != nil { 278 | return fmt.Errorf("InitializeGoogleLogin: %s", err) 279 | 280 | } 281 | 282 | if err := InitializeAWS(); err != nil { 283 | return fmt.Errorf("InitializeAWS: %s", err) 284 | } 285 | 286 | // TODO(ross): Strict-Transport-Security: max-age=31536000; includeSubDomains; preload 287 | 288 | http.HandleFunc("/", GetRoot) 289 | http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 290 | http.Error(w, "Not Found", http.StatusNotFound) 291 | }) 292 | http.HandleFunc("/oauth2callback", GetCallback) 293 | return nil 294 | } 295 | -------------------------------------------------------------------------------- /cloudformation.template: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "aws auth proxy", 4 | "Parameters": { 5 | "DnsName": { 6 | "Description": "DNS name", 7 | "Type": "String", 8 | "Default": "aws.example.com" 9 | }, 10 | "KeyPair": { 11 | "Description": "Keypair", 12 | "Type": "String", 13 | "Default": "" 14 | }, 15 | "FrontendSSLCertificateARN": { 16 | "Description": "Frontend SSL Certificate ARN", 17 | "Type": "String", 18 | "Default": "" 19 | }, 20 | "GoogleDomain": { 21 | "Description": "The name of your google apps domain. Only users from this domain are allowed to log in.", 22 | "Type": "String", 23 | "Default": "" 24 | }, 25 | "GoogleClientID": { 26 | "Description": "Your Google OAuth client ID. This is used to enable identity federation. Get yours from https://console.developers.google.com/", 27 | "Type": "String", 28 | "Default": "" 29 | }, 30 | "GoogleClientSecret": { 31 | "Description": "The secret that goes with GoogleClientId", 32 | "Type": "String", 33 | "Default": "" 34 | }, 35 | "GoogleServiceEmail": { 36 | "Description": "Your Google service account email address and private key. This is used to determine a users group membership", 37 | "Type": "String", 38 | "Default": "" 39 | }, 40 | "GoogleServicePrivateKey": { 41 | "Description": "The private kye that goes with GoogleServiceEmail", 42 | "Type": "String", 43 | "Default": "" 44 | }, 45 | "GoogleServiceUser": { 46 | "Description": "The Google service account requires a user to impersonate when checking the directory to see which groups a user is in. Specify this user here. This user is also used to test the directory service at startup.", 47 | "Type": "String", 48 | "Default": "" 49 | }, 50 | "DockerImage": { 51 | "Description": "Docker image for the auth proxy", 52 | "Type": "String", 53 | "Default": "crewjam/awsauthproxy:latest" 54 | } 55 | }, 56 | "Mappings": { 57 | "RegionMap": { 58 | "us-east-1": { 59 | "AMI": "ami-76e27e1e" 60 | } 61 | } 62 | }, 63 | "Resources": { 64 | "FederationUser": { 65 | "Type": "AWS::IAM::User", 66 | "Properties" : { 67 | "Policies": [{ 68 | "PolicyName" : "AllowGetFederationToken", 69 | "PolicyDocument" : { 70 | "Version": "2012-10-17", 71 | "Statement": [ 72 | { 73 | "Effect": "Allow", 74 | "Action": "sts:GetFederationToken", 75 | "Resource": "*" 76 | } 77 | ] 78 | } 79 | }, 80 | { 81 | "PolicyName" : "MaxAllowedAccessOfFederatedUsers", 82 | "PolicyDocument" : { 83 | "Version": "2012-10-17", 84 | "Statement": [ 85 | { 86 | "Action": ["iam:List*","iam:Get*","iam:PassRole"], 87 | "Resource": "*", 88 | "Effect": "Allow" 89 | }, 90 | { 91 | "Effect": "Allow", 92 | "NotAction": "iam:*", 93 | "Resource": "*" 94 | }, 95 | { 96 | "Action": ["cloudformation:*"], 97 | "Effect": "Deny", 98 | "Resource": {"Fn::Join": ["", ["arn:aws:cloudformation:", 99 | {"Ref": "AWS::Region"}, ":", {"Ref": "AWS::AccountId"}, ":stack/", 100 | {"Ref": "AWS::StackName"}, "/*"]]} 101 | } 102 | ] 103 | } 104 | }] 105 | } 106 | }, 107 | "FederationUserAccessKey": { 108 | "Type": "AWS::IAM::AccessKey", 109 | "Properties": { 110 | "Serial": 4, 111 | "UserName": {"Ref": "FederationUser"} 112 | } 113 | }, 114 | "LoadBalancer": { 115 | "Type": "AWS::ElasticLoadBalancing::LoadBalancer", 116 | "Properties": { 117 | "ConnectionDrainingPolicy": { 118 | "Enabled": true, 119 | "Timeout": 30 120 | }, 121 | "CrossZone": true, 122 | "HealthCheck": { 123 | "HealthyThreshold": "2", 124 | "Interval": "6", 125 | "Target": "TCP:80", 126 | "Timeout": "5", 127 | "UnhealthyThreshold": "2" 128 | }, 129 | "AvailabilityZones": [ 130 | { 131 | "Fn::Select": [ 132 | "1", 133 | { 134 | "Fn::GetAZs": { 135 | "Ref": "AWS::Region" 136 | } 137 | } 138 | ] 139 | }, 140 | { 141 | "Fn::Select": [ 142 | "2", 143 | { 144 | "Fn::GetAZs": { 145 | "Ref": "AWS::Region" 146 | } 147 | } 148 | ] 149 | }, 150 | { 151 | "Fn::Select": [ 152 | "3", 153 | { 154 | "Fn::GetAZs": { 155 | "Ref": "AWS::Region" 156 | } 157 | } 158 | ] 159 | } 160 | ], 161 | "Listeners": [ 162 | { 163 | "InstancePort": "80", 164 | "InstanceProtocol": "HTTP", 165 | "LoadBalancerPort": "443", 166 | "Protocol": "HTTPS", 167 | "SSLCertificateId": {"Ref": "FrontendSSLCertificateARN"} 168 | } 169 | ], 170 | "SecurityGroups": [ 171 | {"Fn::GetAtt": ["LoadBalancerSecurityGroup", "GroupId"]} 172 | ] 173 | } 174 | }, 175 | "LoadBalancerSecurityGroup": { 176 | "Type": "AWS::EC2::SecurityGroup", 177 | "Properties": { 178 | "GroupDescription": "Enable SSH access", 179 | "SecurityGroupIngress": [ 180 | { 181 | "IpProtocol": "tcp", 182 | "FromPort": "443", 183 | "ToPort": "443", 184 | "CidrIp": "0.0.0.0/0" 185 | } 186 | ] 187 | } 188 | }, 189 | "SecurityGroup": { 190 | "Type": "AWS::EC2::SecurityGroup", 191 | "Properties": { 192 | "GroupDescription": "Enable SSH access", 193 | "SecurityGroupIngress": [ 194 | { 195 | "IpProtocol": "tcp", 196 | "FromPort": "22", 197 | "ToPort": "22", 198 | "CidrIp": "0.0.0.0/0" 199 | }, 200 | { 201 | "IpProtocol": "tcp", 202 | "FromPort": "80", 203 | "ToPort": "80", 204 | "SourceSecurityGroupOwnerId": { 205 | "Fn::GetAtt": [ 206 | "LoadBalancer", 207 | "SourceSecurityGroup.OwnerAlias" 208 | ] 209 | }, 210 | "SourceSecurityGroupName": { 211 | "Fn::GetAtt": ["LoadBalancer", "SourceSecurityGroup.GroupName"] 212 | } 213 | } 214 | ] 215 | } 216 | }, 217 | "ASG": { 218 | "Type": "AWS::AutoScaling::AutoScalingGroup", 219 | "Properties": { 220 | "AvailabilityZones": [ 221 | { 222 | "Fn::Select": [ 223 | "1", 224 | { 225 | "Fn::GetAZs": { 226 | "Ref": "AWS::Region" 227 | } 228 | } 229 | ] 230 | }, 231 | { 232 | "Fn::Select": [ 233 | "2", 234 | { 235 | "Fn::GetAZs": { 236 | "Ref": "AWS::Region" 237 | } 238 | } 239 | ] 240 | }, 241 | { 242 | "Fn::Select": [ 243 | "3", 244 | { 245 | "Fn::GetAZs": { 246 | "Ref": "AWS::Region" 247 | } 248 | } 249 | ] 250 | } 251 | ], 252 | "MaxSize": "1", 253 | "MinSize": "1", 254 | "HealthCheckGracePeriod": "600", 255 | "HealthCheckType": "ELB", 256 | "LaunchConfigurationName": { 257 | "Ref": "LaunchConfig" 258 | }, 259 | "LoadBalancerNames": [ 260 | {"Ref": "LoadBalancer"} 261 | ], 262 | "Tags": [ 263 | { 264 | "PropagateAtLaunch": true, 265 | "Key": "Name", 266 | "Value": { 267 | "Ref": "DnsName" 268 | } 269 | } 270 | ] 271 | } 272 | }, 273 | "LaunchConfig": { 274 | "Type": "AWS::AutoScaling::LaunchConfiguration", 275 | "Metadata": { 276 | "SecretAccessKey": {"Fn::GetAtt": ["FederationUserAccessKey", "SecretAccessKey"]}, 277 | "GoogleClientSecret": {"Ref": "GoogleClientSecret"}, 278 | "GoogleServicePrivateKey": {"Ref": "GoogleServicePrivateKey"} 279 | }, 280 | "Properties": { 281 | "ImageId": { 282 | "Fn::FindInMap": [ 283 | "RegionMap", 284 | { 285 | "Ref": "AWS::Region" 286 | }, 287 | "AMI" 288 | ] 289 | }, 290 | "InstanceType": "t2.micro", 291 | "KeyName": { 292 | "Ref": "KeyPair" 293 | }, 294 | "SecurityGroups": [ 295 | { 296 | "Ref": "SecurityGroup" 297 | } 298 | ], 299 | "UserData": { 300 | "Fn::Base64": { 301 | "Fn::Join": [ 302 | "", 303 | [ 304 | "#!/bin/bash\n", 305 | "set -ex\n", 306 | "curl https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz | tar -xz --strip-components=1\n", 307 | "python setup.py install\n", 308 | "curl -sSL https://get.docker.com/ | sh\n", 309 | "docker run -d", 310 | " -e AWSAUTHD_TRUST_X_FORWARDED=true", 311 | " -e AWSAUTHD_AWS_ACCESS_KEY_ID=", {"Ref": "FederationUserAccessKey"}, 312 | " -e AWSAUTHD_AWS_SECRET_ACCESS_KEY=$(cfn-get-metadata -v -s ", {"Ref": "AWS::StackName"}, " -r LaunchConfig -k SecretAccessKey)", 313 | " -e AWSAUTHD_GOOGLE_DOMAIN=", {"Ref": "GoogleDomain"}, 314 | " -e AWSAUTHD_GOOGLE_CLIENT_ID=", {"Ref": "GoogleClientID"}, 315 | " -e AWSAUTHD_GOOGLE_CLIENT_SECRET=$(cfn-get-metadata -v -s ", {"Ref": "AWS::StackName"}, " -r LaunchConfig -k GoogleClientSecret)", 316 | " -e AWSAUTHD_GOOGLE_SERVICE_EMAIL=", {"Ref": "GoogleServiceEmail"}, 317 | " -e AWSAUTHD_GOOGLE_SERVICE_PRIVATE_KEY=\"$(cfn-get-metadata -v -s ", {"Ref": "AWS::StackName"}, " -r LaunchConfig -k GoogleServicePrivateKey)\"", 318 | " -e AWSAUTHD_GOOGLE_SERVICE_USER=", {"Ref": "GoogleServiceUser"}, 319 | " -p 80:80", 320 | " ", {"Ref": "DockerImage"}, 321 | " awsauthd -listen=0.0.0.0:80\n" 322 | ] 323 | ] 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | --------------------------------------------------------------------------------