aws-oidc working with google and onelogin openid connect.

This commit is contained in:
Jeremy Stott 2019-04-11 00:08:13 +12:00
commit 653521ed9f
9 changed files with 533 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
aws-oidc

46
README.md Normal file
View File

@ -0,0 +1,46 @@
# aws-oidc
Assume roles in AWS using an OpenID Connect Identity provider.
It outputs temporary AWS credentials in a JSON format that can be consumed by the credentials_process setting in ~/.aws/config.
https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes
OneLogin Example:
aws-oidc exec \
--role_arn="arn:aws:iam::892845094662:role/onelogin-test-oidc" \
--provider_url=https://openid-connect.onelogin.com/oidc \
--client_id=97a61160-3c09-0137-8c69-0a1c3f4fd822144813 \
--pkce \
--nonce \
-- open -b com.google.chrome -n --args --profile-directory=Default {}
Google Example:
aws-oidc exec \
--role_arn="arn:aws:iam::892845094662:role/web-identity-lanbda" \
--provider_url=https://accounts.google.com \
--client_id=430784603061-osbtei3s71l0bj6d8oegto0itefjmiq6.apps.googleusercontent.com \
--client_secret=... \
--pkce \
--nonce \
-- open -b com.google.chrome -n --args --profile-directory=Default {}
For some reason, even when using PKCE, google need a client_secret for applications not registered as Android, iOS or Chrome.
## Configure AWS Config
~/.aws/config
[profile oidc]
credential_process = aws-oidc exec --role_arn=arn:aws:iam::892845094662:role/onelogin-test-oidc --provider_url=https://openid-connect.onelogin.com/oidc --client_id=97a61160-3c09-0137-8c69-0a1c3f4fd822144813 --pkce --nonce -- open -b com.google.chrome -n --args --profile-directory=Default {}
Now you can use the AWS cli as normal, and specify the profile:
$ aws --profile oidc sts get-caller-identity
{
"UserId": "AROAJUTXNWXGCAEILMXTY:50904038",
"Account": "892845094662",
"Arn": "arn:aws:sts::892845094662:assumed-role/onelogin-test-oidc/50904038"
}

46
aws-oidc.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"log"
"os"
"github.com/stoggi/aws-oidc/cli"
"gopkg.in/alecthomas/kingpin.v2"
)
// Version is provided at compile time
var Version = "dev"
var labelText chan string
func main() {
run(os.Args[1:], os.Exit)
}
func run(args []string, exit func(int)) {
f, err := os.OpenFile(GetLogPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("error opening file: %v", err)
}
defer f.Close()
log.SetOutput(f)
log.Println("Starting...")
// Default configuration, values are overridden by command line options.
config := cli.GlobalConfig{}
app := kingpin.New(
"aws-oidc",
"Assume roles in AWS using an OIDC identity provider",
)
app.Writer(os.Stdout)
app.Version(Version)
app.Terminate(exit)
cli.ConfigureGlobal(app, &config)
cli.ConfigureExec(app, &config)
kingpin.MustParse(app.Parse(args))
}

126
cli/exec.go Normal file
View File

@ -0,0 +1,126 @@
package cli
import (
"encoding/json"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/stoggi/aws-oidc/provider"
kingpin "gopkg.in/alecthomas/kingpin.v2"
)
type ExecConfig struct {
RoleArn string
Duration int64
ProviderURL string
ClientID string
ClientSecret string
PKCE bool
Nonce bool
ReAuth bool
AgentCommant []string
}
// json metadata for AWS credential process. Ref: https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes
type AwsCredentialHelperData struct {
Version int `json:"Version"`
AccessKeyID string `json:"AccessKeyId"`
SecretAccessKey string `json:"SecretAccessKey"`
SessionToken string `json:"SessionToken"`
Expiration string `json:"Expiration,omitempty"`
}
func ConfigureExec(app *kingpin.Application, config *GlobalConfig) {
execConfig := ExecConfig{}
cmd := app.Command("exec", "Execute a command with temporary AWS credentials")
cmd.Default()
cmd.Flag("role_arn", "The AWS role you want to assume").
Required().
StringVar(&execConfig.RoleArn)
cmd.Flag("duration", "The duration to assume the role for in seconds").
Default("3600").
Int64Var(&execConfig.Duration)
cmd.Flag("provider_url", "The OpenID Connect Provider URL").
Required().
StringVar(&execConfig.ProviderURL)
cmd.Flag("client_id", "The OpenID Connect Client ID").
Required().
StringVar(&execConfig.ClientID)
cmd.Flag("client_secret", "The OpenID Connect Client Secret").
Default("").
StringVar(&execConfig.ClientSecret)
cmd.Flag("pkce", "Use PKCE in the OIDC code flow").
Default("true").
BoolVar(&execConfig.PKCE)
cmd.Flag("nonce", "Require a nonce included and verified in the token").
Default("true").
BoolVar(&execConfig.Nonce)
cmd.Flag("reauth", "Require reauthentication by the identity provider").
Default("false").
BoolVar(&execConfig.ReAuth)
cmd.Arg("agent", "The executable and arguments of the local browser to use").
Default("open", "{}").
StringsVar(&execConfig.AgentCommant)
cmd.Action(func(c *kingpin.ParseContext) error {
ExecCommand(app, config, &execConfig)
return nil
})
}
func ExecCommand(app *kingpin.Application, config *GlobalConfig, execConfig *ExecConfig) {
providerConfig := &provider.ProviderConfig{
ClientID: execConfig.ClientID,
ClientSecret: execConfig.ClientSecret,
ProviderURL: execConfig.ProviderURL,
PKCE: execConfig.PKCE,
Nonce: execConfig.Nonce,
ReAuth: execConfig.ReAuth,
AgentCommand: execConfig.AgentCommant,
}
authResult, err := provider.Authenticate(providerConfig)
app.FatalIfError(err, "Error authenticating to identity provider: %v", err)
svc := sts.New(session.New())
input := &sts.AssumeRoleWithWebIdentityInput{
DurationSeconds: aws.Int64(execConfig.Duration),
RoleArn: aws.String(execConfig.RoleArn),
RoleSessionName: aws.String(authResult.Token.Subject),
WebIdentityToken: aws.String(authResult.JWT),
}
assumeRoleResult, err := svc.AssumeRoleWithWebIdentity(input)
app.FatalIfError(err, "Unable to assume role: %v", err)
expiry := *assumeRoleResult.Credentials.Expiration
credentialData := AwsCredentialHelperData{
Version: 1,
AccessKeyID: *assumeRoleResult.Credentials.AccessKeyId,
SecretAccessKey: *assumeRoleResult.Credentials.SecretAccessKey,
SessionToken: *assumeRoleResult.Credentials.SessionToken,
Expiration: expiry.Format("2006-01-02T15:04:05Z"),
}
json, err := json.Marshal(&credentialData)
if err != nil {
app.Fatalf("Error creating credential json")
}
fmt.Printf(string(json))
}

41
cli/global.go Normal file
View File

@ -0,0 +1,41 @@
package cli
import (
"github.com/99designs/aws-vault/vault"
"github.com/99designs/keyring"
kingpin "gopkg.in/alecthomas/kingpin.v2"
)
// GlobalConfig used for defaults and command line arguments
type GlobalConfig struct {
//Region in AWS used by KMSAuth and BLESS
Region string
AwsConfig *vault.Config
Keyring *keyring.Keyring
}
// ConfigureGlobal application arguments and flags
func ConfigureGlobal(app *kingpin.Application, config *GlobalConfig) {
app.Flag("region", "The region in AWS").
Default("ap-southeast-2").
StringVar(&config.Region)
app.PreAction(func(c *kingpin.ParseContext) (err error) {
// Attempt to open the aws-vault keychain
keychain, err := keyring.Open(keyring.Config{
KeychainName: "aws-vault",
ServiceName: "aws-vault",
AllowedBackends: []keyring.BackendType{keyring.KeychainBackend},
})
kingpin.FatalIfError(err, "Could not open aws-vault keychain")
//config.AwsConfig = awsConfig
config.Keyring = &keychain
return nil
})
}

26
config_darwin.go Normal file
View File

@ -0,0 +1,26 @@
package main
import (
"os"
"os/user"
"path/filepath"
)
func homeDir() string {
if currentUser, err := user.Current(); err == nil {
return currentUser.HomeDir
}
return ""
}
func execDir() string {
if currentExecutable, err := os.Executable(); err == nil {
return filepath.Dir(currentExecutable)
}
return ""
}
// GetLogPath returns the path that should be used to store logs
func GetLogPath() string {
return filepath.Join(homeDir(), "./Library/Logs/aws-oidc.log")
}

19
go.mod Normal file
View File

@ -0,0 +1,19 @@
module github.com/stoggi/aws-oidc
require (
github.com/99designs/aws-vault v4.5.1+incompatible
github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/aws/aws-sdk-go v1.19.11
github.com/coreos/go-oidc v2.0.0+incompatible
github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a // indirect
github.com/go-ini/ini v1.42.0 // indirect
github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3
golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/square/go-jose.v2 v2.3.0 // indirect
)

43
go.sum Normal file
View File

@ -0,0 +1,43 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/99designs/aws-vault v4.5.1+incompatible h1:VjWncFWraO5K5HTRo34YMq2MkpKYphZy5luMSe76pkg=
github.com/99designs/aws-vault v4.5.1+incompatible/go.mod h1:BKt7gBiUkiAOh7TP/c36gMpRJkIk5F8hStyQoWwC/Rw=
github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f h1:WXiWWJrYCaOaYimBAXlRdRJ7qOisrYyMLYnCvvhHVms=
github.com/99designs/keyring v0.0.0-20190110203331-82da6802f65f/go.mod h1:aKt8W/yd91/xHY6ixZAJZ2vYbhr3pP8DcrvuGSGNPJk=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/aws/aws-sdk-go v1.19.11 h1:tqaTGER6Byw3QvsjGW0p018U2UOqaJPeJuzoaF7jjoQ=
github.com/aws/aws-sdk-go v1.19.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/coreos/go-oidc v2.0.0+incompatible h1:+RStIopZ8wooMx+Vs5Bt8zMXxV1ABl5LbakNExNmZIg=
github.com/coreos/go-oidc v2.0.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a h1:mq+R6XEM6lJX5VlLyZIrUSP8tSuJp82xTK89hvBwJbU=
github.com/dvsekhvalnov/jose2go v0.0.0-20180829124132-7f401d37b68a/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
github.com/go-ini/ini v1.42.0 h1:TWr1wGj35+UiWHlBA8er89seFXxzwFn11spilrrj+38=
github.com/go-ini/ini v1.42.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6 h1:hfM5TYph19rQBp3oOg4SVckf4ZmYrycciBJCWmxOcIE=
github.com/keybase/go-keychain v0.0.0-20190408194155-7f2ef9fddce6/go.mod h1:JJNrCn9otv/2QP4D7SMJBgaleKpOf66PnW6F5WGNRIc=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a h1:tImsplftrFpALCYumobsd0K86vlAs/eXGFms2txfJfA=
golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/square/go-jose.v2 v2.3.0 h1:nLzhkFyl5bkblqYBoiWJUt5JkWOzmiaBtCxdJAqJd3U=
gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=

185
provider/provider.go Normal file
View File

@ -0,0 +1,185 @@
package provider
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"net"
"net/http"
"os/exec"
"strings"
oidc "github.com/coreos/go-oidc"
"golang.org/x/net/context"
"golang.org/x/oauth2"
)
type ProviderConfig struct {
ClientID string
ClientSecret string
ProviderURL string
PKCE bool
Nonce bool
ReAuth bool
AgentCommand []string
}
type Result struct {
JWT string
Token *oidc.IDToken
}
type TokenClaims struct {
Issuer string `json:"iss"`
Audience string `json:"aud"`
Subject string `json:"sub"`
Picture string `json:"picture"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
func Authenticate(p *ProviderConfig) (Result, error) {
ctx := context.Background()
resultChannel := make(chan Result, 0)
errorChannel := make(chan error, 0)
provider, err := oidc.NewProvider(ctx, p.ProviderURL)
if err != nil {
return Result{"", nil}, err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return Result{"", nil}, err
}
baseURL := "http://" + listener.Addr().String()
redirectURL := baseURL + "/auth/callback"
oidcConfig := &oidc.Config{
ClientID: p.ClientID,
SupportedSigningAlgs: []string{"RS256"},
}
verifier := provider.Verifier(oidcConfig)
config := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
stateData := make([]byte, 32)
if _, err = rand.Read(stateData); err != nil {
return Result{"", nil}, err
}
state := base64.URLEncoding.EncodeToString(stateData)
codeData := make([]byte, 32)
if _, err = rand.Read(codeData); err != nil {
return Result{"", nil}, err
}
codeVerifier := base64.StdEncoding.EncodeToString(codeData)
codeDigest := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.URLEncoding.EncodeToString(codeDigest[:])
codeChallengeEncoded := strings.Replace(codeChallenge, "=", "", -1)
nonceData := make([]byte, 32)
_, err = rand.Read(nonceData)
nonce := base64.URLEncoding.EncodeToString(nonceData)
authCodeOptions := []oauth2.AuthCodeOption{}
tokenCodeOptions := []oauth2.AuthCodeOption{}
if p.PKCE {
authCodeOptions = append(authCodeOptions,
oauth2.SetAuthURLParam("code_challenge", codeChallengeEncoded),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
tokenCodeOptions = append(tokenCodeOptions,
oauth2.SetAuthURLParam("code_verifier", codeVerifier),
)
}
if p.Nonce {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("nonce", nonce))
}
if p.ReAuth {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("acr_values", "onelogin:nist:level:1:re-auth"))
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
url := config.AuthCodeURL(state, authCodeOptions...)
http.Redirect(w, r, url, http.StatusFound)
})
http.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != state {
http.Error(w, "state did not match", http.StatusBadRequest)
errorChannel <- errors.New("State did not match")
return
}
oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"), tokenCodeOptions...)
if err != nil {
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
errorChannel <- errors.New("Failed to exchange token: " + err.Error())
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError)
errorChannel <- errors.New("No id_token field in oauth2 token")
return
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
errorChannel <- errors.New("Failed to verify ID Token: " + err.Error())
return
}
if p.Nonce && idToken.Nonce != nonce {
http.Error(w, "Failed to verify Nonce", http.StatusInternalServerError)
errorChannel <- errors.New("Failed to verify Nonce")
return
}
var claims = new(TokenClaims)
if err := idToken.Claims(&claims); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
errorChannel <- errors.New("Failed to verify Claims: " + err.Error())
return
}
w.Write([]byte("Signed in successfully, return to cli app"))
resultChannel <- Result{rawIDToken, idToken}
})
// Filter the commands, and replace "{}" with our callback url
c := p.AgentCommand[:0]
for _, arg := range p.AgentCommand {
if arg == "{}" {
c = append(c, baseURL)
} else {
c = append(c, arg)
}
}
cmd := exec.Command(c[0], c[1:]...)
cmd.Run()
server := &http.Server{}
go func() {
server.Serve(listener)
}()
select {
case err := <-errorChannel:
server.Shutdown(ctx)
return Result{}, err
case res := <-resultChannel:
server.Shutdown(ctx)
return res, nil
}
}