Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make library generic to all OIDC Providers #14

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
# k8s-oidc-helper

This is a small helper tool to get a user get authenticated with
[Kubernetes OIDC](http://kubernetes.io/docs/admin/authentication/) using Google
[Kubernetes OIDC](http://kubernetes.io/docs/admin/authentication/) using Any OpenID Connect Provider
as the Identity Provider.

Given a ClientID and ClientSecret, the tool will output the necessary
Given a ClientID, ClientSecret and Issuer URL, the tool will output the necessary
configuration for `kubectl` that you can add to `~/.kube/config`

```
$ k8s-oidc-helper -c ./client_secret.json
$ k8s-oidc-helper -c ./client_secret.json # Out of the Box Support for Google;s JSON File
Enter the code Google gave you: <code>

# Add the following to your ~/.kube/config
Expand All @@ -27,7 +27,30 @@ users:
refresh-token: <refresh-token>
name: oidc
```

Using Auth0 as your OIDC Provider
```
~/go/bin/k8s-oidc-helper --issuer-url https://your-app.auth0.com --client-id <client_id> --client-secret <client_secret>
Enter the code Provider gave you (On The page or the Value of `code` query parameter on localhost URL) : <code>
# Auth0 code sometimes ends with #, when # is not actually part of the code value itself, remove it in case you are facing errors
# Add the following to your ~/.kube/config
apiVersion: v1
clusters: []
contexts: []
current-context: ""
kind: Config
preferences: {}
users:
- name: [email protected]
user:
auth-provider:
config:
client-id: <client_id>
client-secret: <client_secret>
id-token: <id_token>
idp-issuer-url: https://your-app.auth0.com
refresh-token: <refresh_token>
name: oidc
```
To merge the new configuration into your existing kubectl config file, run:

```
Expand Down Expand Up @@ -56,7 +79,7 @@ Second, your kube-apiserver will need the following flags on to use OpenID Conne

```
--oidc-issuer-url=https://accounts.google.com \
--oidc-username-claim=email \
--oidc-username-claim=email \ # tool supports email, sub and name claims make sure this value matches the --user-claim argument
--oidc-client-id=<Your client ID>\
```

Expand Down Expand Up @@ -98,12 +121,16 @@ go get github.com/micahhausler/k8s-oidc-helper
## Usage

```
Usage of k8s-oidc-helper:
Usage of /Users/sbhave/go/bin/k8s-oidc-helper:
--client-id string The ClientID for the application
--client-secret string The ClientSecret for the application
-c, --config string Path to a json file containing your application's ClientID and ClientSecret. Supercedes the --client-id and --client-secret flags.
-c, --config string Path to a json file containing your Google application's ClientID and ClientSecret. Supercedes the --client-id and --client-secret flags.
--file ~/.kube/config The file to write to. If not specified, ~/.kube/config is used
--issuer-url string OIDC Discovery URL, such that <URL>/.well-known/openid-configuration can be fetched
-o, --open Open the oauth approval URL in the browser (default true)
--redirect_uri string http://localhost or urn:ietf:wg:oauth:2.0:oob if --config flag is used for google OpenID (default "http://localhost")
--scopes string Required scopes to be passed to the Authicator. offline_access is added if access_type parameter is not supported by authorizer (default "openid email")
--user-claim string The Claim in ID-Token used to identify the user. One of sub/email/name (default "email")
-v, --version Print version and exit
-w, --write Write config to file. Merges in the specified file
```
Expand Down
101 changes: 84 additions & 17 deletions internal/helper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package helper

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"

clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
Expand All @@ -27,6 +29,14 @@ type TokenResponse struct {
IdToken string `json:"id_token"`
}

type DiscoverySpec struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}

func ReadConfig(path string) (*GoogleConfig, error) {
f, err := os.Open(path)
defer f.Close()
Expand All @@ -42,15 +52,15 @@ func ReadConfig(path string) (*GoogleConfig, error) {
}

// Get the id_token and refresh_token from google
func GetToken(clientID, clientSecret, code string) (*TokenResponse, error) {
func GetToken(ds DiscoverySpec, clientID, clientSecret, code string, redirectUri string) (*TokenResponse, error) {
val := url.Values{}
val.Add("grant_type", "authorization_code")
val.Add("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
val.Add("redirect_uri", redirectUri)
val.Add("client_id", clientID)
val.Add("client_secret", clientSecret)
val.Add("code", code)

resp, err := http.PostForm("https://www.googleapis.com/oauth2/v3/token", val)
resp, err := http.PostForm(ds.TokenEndpoint, val)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -88,10 +98,12 @@ type APConfig struct {

type UserInfo struct {
Email string `json:"email"`
Sub string `json:"sub"`
Name string `json:"name"`
}

func GetUserEmail(accessToken string) (string, error) {
uri, _ := url.Parse("https://www.googleapis.com/oauth2/v1/userinfo")
func GetUserClaim(ds DiscoverySpec, accessToken, userClaim string) (string, error) {
uri, _ := url.Parse(ds.UserinfoEndpoint)
q := uri.Query()
q.Set("alt", "json")
q.Set("access_token", accessToken)
Expand All @@ -107,46 +119,59 @@ func GetUserEmail(accessToken string) (string, error) {
if err != nil {
return "", err
}
return ui.Email, nil
var retVal string
switch userClaim {
case "email":
retVal = ui.Email
case "sub":
retVal = ui.Sub
case "name":
retVal = ui.Name
default:
return "", errors.New("User Claim needs to be on of sub/name/email")
}

if retVal == "" {
return "", fmt.Errorf("UserInfo Endpoint does not support provided claim: %s", userClaim)
} else {
return retVal, nil
}
}

func GenerateAuthInfo(clientId, clientSecret, idToken, refreshToken string) *clientcmdapi.AuthInfo {
func GenerateAuthInfo(issuer, clientId, clientSecret, idToken, refreshToken string) *clientcmdapi.AuthInfo {
return &clientcmdapi.AuthInfo{
AuthProvider: &clientcmdapi.AuthProviderConfig{
Name: "oidc",
Config: map[string]string{
"client-id": clientId,
"client-secret": clientSecret,
"id-token": idToken,
"idp-issuer-url": "https://accounts.google.com",
"idp-issuer-url": issuer,
"refresh-token": refreshToken,
},
},
}
}

func createOpenCmd(oauthUrl, clientID string) (*exec.Cmd, error) {
url := fmt.Sprintf(oauthUrl, clientID)

func createOpenCmd(oauthUrl string) (*exec.Cmd, error) {
switch os := runtime.GOOS; os {
case "darwin":
return exec.Command("open", url), nil
return exec.Command("open", oauthUrl), nil
case "linux":
return exec.Command("xdg-open", url), nil
return exec.Command("xdg-open", oauthUrl), nil
}

return nil, fmt.Errorf("Could not detect the open command for OS: %s", runtime.GOOS)
}

func LaunchBrowser(openBrowser bool, oauthUrl, clientID string) {
openInstructions := fmt.Sprintf("Open this url in your browser: %s\n", fmt.Sprintf(oauthUrl, clientID))
func LaunchBrowser(openBrowser bool, oauthUrl string) {
openInstructions := fmt.Sprintf("Open this url in your browser: %s\n", oauthUrl)

if !openBrowser {
fmt.Print(openInstructions)
return
}

cmd, err := createOpenCmd(oauthUrl, clientID)
cmd, err := createOpenCmd(oauthUrl)
if err != nil {
fmt.Print(openInstructions)
return
Expand All @@ -157,3 +182,45 @@ func LaunchBrowser(openBrowser bool, oauthUrl, clientID string) {
fmt.Print(openInstructions)
}
}

func ConstructAuthUrl(discoverySpec DiscoverySpec, scopes string, redirectUri string, clientID string) string {
authURL, _ := url.Parse(discoverySpec.AuthorizationEndpoint)
q := authURL.Query()
// Some providers like Google accept a diiferent Query Parameter called access_type, Some Like Auth0 support it as a scope value, And Some like Gitlab Always give refresh tokens
if contains(discoverySpec.ScopesSupported, "offline_access") {
scopes = strings.TrimSpace(scopes) + " offline_access"
}
q.Set("scope", scopes)
q.Set("redirect_uri", redirectUri)
//TODO: check whether response_type is supported and throw error accordingly, but almost all the providers support code method
q.Set("response_type", "code")
q.Set("client_id", clientID)
q.Set("approval_prompt", "force") // Providers who dont support it should ignore any extra parameters
q.Set("access_type", "offline") // Providers who dont support it should ignore any extra parameters

authURL.RawQuery = q.Encode()
return authURL.String()
}

func GetDiscoverySpec(issuer string) (DiscoverySpec, error) {
ds := &DiscoverySpec{}
resp, err := http.Get(issuer + "/.well-known/openid-configuration")
if err != nil {
return *ds, err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(ds)
if err != nil {
return *ds, err
}
return *ds, nil
}

func contains(s []string, e string) bool {
for _, a := range s {
if strings.Compare(a, e) == 0 {
return true
}
}
return false
}
38 changes: 29 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"io/ioutil"
"log"
"os"
"os/user"
"path/filepath"
Expand All @@ -19,18 +20,20 @@ import (
clientcmdlatest "k8s.io/client-go/tools/clientcmd/api/latest"
)

const Version = "v0.1.0"

const oauthUrl = "https://accounts.google.com/o/oauth2/auth?redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&client_id=%s&scope=openid+email+profile&approval_prompt=force&access_type=offline"
const Version = "v0.2.0"

func main() {
flag.BoolP("version", "v", false, "Print version and exit")
flag.BoolP("open", "o", true, "Open the oauth approval URL in the browser")
flag.String("client-id", "", "The ClientID for the application")
flag.String("client-secret", "", "The ClientSecret for the application")
flag.StringP("config", "c", "", "Path to a json file containing your application's ClientID and ClientSecret. Supercedes the --client-id and --client-secret flags.")
flag.StringP("config", "c", "", "Path to a json file containing your Google application's ClientID and ClientSecret. Supercedes the --client-id and --client-secret flags.")
flag.BoolP("write", "w", false, "Write config to file. Merges in the specified file")
flag.String("file", "", "The file to write to. If not specified, `~/.kube/config` is used")
flag.String("issuer-url", "", "OIDC Discovery URL, such that <URL>/.well-known/openid-configuration can be fetched")
flag.String("scopes", "openid email", "Required scopes to be passed to the Authicator. offline_access is added if access_type parameter is not supported by authorizer")
flag.String("redirect_uri", "http://localhost", "http://localhost or urn:ietf:wg:oauth:2.0:oob if --config flag is used for google OpenID")
flag.String("user-claim", "email", "The Claim in ID-Token used to identify the user. One of sub/email/name")

viper.BindPFlags(flag.CommandLine)
viper.SetEnvPrefix("k8s-oidc-helper")
Expand Down Expand Up @@ -64,26 +67,43 @@ func main() {
clientSecret = viper.GetString("client-secret")
}

helper.LaunchBrowser(viper.GetBool("open"), oauthUrl, clientID)
var issuerUrl string
redirectUri := viper.GetString("redirect_uri")
if viper.GetString("issuer-url") == "" {
issuerUrl = "https://accounts.google.com"
} else {
issuerUrl = viper.GetString("issuer-url")
}

if strings.HasPrefix(issuerUrl, "https://accounts.google.com") {
redirectUri = "urn:ietf:wg:oauth:2.0:oob"
}

ds, err := helper.GetDiscoverySpec(issuerUrl)
if err != nil {
log.Fatalf("Can not get Discovery Spec, Please make sure that <URL>/.well-known/openid-configuration return OpenID JSON: %v", err)
}

helper.LaunchBrowser(viper.GetBool("open"), helper.ConstructAuthUrl(ds, viper.GetString("scopes"), redirectUri, clientID))

reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter the code Google gave you: ")
fmt.Print("Enter the code Provider gave you (On The page or the Value of `code` query parameter on localhost URL) : ")
code, _ := reader.ReadString('\n')
code = strings.TrimSpace(code)

tokResponse, err := helper.GetToken(clientID, clientSecret, code)
tokResponse, err := helper.GetToken(ds, clientID, clientSecret, code, redirectUri)
if err != nil {
fmt.Printf("Error getting tokens: %s\n", err)
os.Exit(1)
}

email, err := helper.GetUserEmail(tokResponse.AccessToken)
email, err := helper.GetUserClaim(ds, tokResponse.AccessToken, viper.GetString("user-claim"))
if err != nil {
fmt.Printf("Error getting user email: %s\n", err)
os.Exit(1)
}

authInfo := helper.GenerateAuthInfo(clientID, clientSecret, tokResponse.IdToken, tokResponse.RefreshToken)
authInfo := helper.GenerateAuthInfo(issuerUrl, clientID, clientSecret, tokResponse.IdToken, tokResponse.RefreshToken)
config := &clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{email: authInfo},
}
Expand Down