Skip to content

Commit

Permalink
Support authn-oidc
Browse files Browse the repository at this point in the history
  • Loading branch information
szh committed Dec 19, 2022
1 parent 72c52f7 commit 873394e
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 16 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added
- Added support for Conjur's LDAP authenticator
[cyberark/conjur-api-go#141](https://github.com/cyberark/conjur-api-go/pull/141)
- Added support for Conjur's OIDC authenticator
[cyberark/conjur-api-go#144](https://github.com/cyberark/conjur-api-go/pull/144)

### Removed
- Remove all usage of Conjur v4
[cyberark/conjur-api-go#139](https://github.com/cyberark/conjur-api-go/pull/139)
Expand Down
104 changes: 104 additions & 0 deletions conjurapi/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,39 @@ package conjurapi

import (
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"

"github.com/cyberark/conjur-api-go/conjurapi/authn"
"github.com/cyberark/conjur-api-go/conjurapi/logging"
"github.com/cyberark/conjur-api-go/conjurapi/response"
)

// OidcProviderResponse contains information about an OIDC provider.
type OidcProviderResponse struct {
ServiceID string `json:"service_id"`
Type string `json:"type"`
Name string `json:"name"`
Nonce string `json:"nonce"`
CodeVerifier string `json:"code_verifier"`
RedirectURI string `json:"redirect_uri"`
}

func (c *Client) RefreshToken() (err error) {
var token *authn.AuthnToken

// Fetch cached conjur access token if using OIDC
if c.GetConfig().AuthnType == "oidc" {
token := c.readCachedAccessToken()
if token != nil {
c.authToken = token
}
}

if c.NeedsTokenRefresh() {
var tokenBytes []byte
tokenBytes, err = c.authenticator.RefreshToken()
Expand Down Expand Up @@ -116,6 +138,43 @@ func (c *Client) authenticate(loginPair authn.LoginPair) (*http.Response, error)
return c.httpClient.Do(req)
}

func (c *Client) OidcAuthenticate(code, nonce, code_verifier string) ([]byte, error) {
req, err := c.OidcAuthenticateRequest(code, nonce, code_verifier)
if err != nil {
return nil, err
}

res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}

resp, err := response.DataResponse(res)

if err == nil {
c.cacheAccessToken(resp)
}

return resp, err
}

func (c *Client) ListOidcProviders() ([]OidcProviderResponse, error) {
req, err := c.ListOidcProvidersRequest()
if err != nil {
return nil, err
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}

providers := []OidcProviderResponse{}
err = response.JSONResponse(resp, &providers)

return providers, err
}

// RotateAPIKey replaces the API key of a role on the server with a new
// random secret.
//
Expand Down Expand Up @@ -171,3 +230,48 @@ func (c *Client) rotateAPIKey(roleID string) (*http.Response, error) {

return c.SubmitRequest(req)
}

func (c *Client) oidcTokenPath() string {
oidcTokenPath := c.GetConfig().OidcTokenPath
if oidcTokenPath == "" {
oidcTokenPath = defaultOidcTokenPath
}
return oidcTokenPath
}

// Caches the conjur access token. We only cache this for OIDC since we don't have access
// to the Conjur API key and this is the only credential we can save.
// TODO: Perhaps .netrc storage should be moved to the conjur-api-go repository. At that point we could store
// the access token there as we do with the API key.
func (c *Client) cacheAccessToken(token []byte) error {
if token == nil {
return nil
}

oidcTokenPath := c.oidcTokenPath()

// Ensure the directory exists
_, err := os.Stat(oidcTokenPath)
if err != nil && errors.Is(err, os.ErrNotExist) {
dir := filepath.Dir(oidcTokenPath)
os.MkdirAll(dir, os.ModePerm)
}
err = os.WriteFile(oidcTokenPath, token, 0600)
if err != nil {
logging.ApiLog.Debugf("Failed to write access token to %s: %s", oidcTokenPath, err)
}
return nil
}

// Fetches the cached conjur access token. We only do this for OIDC since we don't have access
// to the Conjur API key and this is the only credential we can save.
func (c *Client) readCachedAccessToken() *authn.AuthnToken {
if contents, err := os.ReadFile(c.oidcTokenPath()); err == nil {
token, err := authn.NewToken(contents)
if err == nil {
token.FromJSON(contents)
return token
}
}
return nil
}
16 changes: 16 additions & 0 deletions conjurapi/authn/oidc_authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package authn

type OidcAuthenticator struct {
Code string
Nonce string
CodeVerifier string
Authenticate func(code, noce, code_verifier string) ([]byte, error)
}

func (a *OidcAuthenticator) RefreshToken() ([]byte, error) {
return a.Authenticate(a.Code, a.Nonce, a.CodeVerifier)
}

func (a *OidcAuthenticator) NeedsTokenRefresh() bool {
return false
}
95 changes: 95 additions & 0 deletions conjurapi/authn_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package conjurapi

import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/cyberark/conjur-api-go/conjurapi/authn"
Expand Down Expand Up @@ -145,3 +150,93 @@ func runRotateUserAPIKeyAssertions(t *testing.T, tc rotateUserAPIKeyTestCase, co
_, err = conjur.Authenticate(authn.LoginPair{Login: tc.login, APIKey: string(userAPIKey)})
assert.NoError(t, err)
}

func TestClient_OidcAuthenticate(t *testing.T) {
testCases := []struct {
name string
tokenPath string
expectFileWriteError bool
}{
{
name: "Caches token to default location",
tokenPath: "",
},
{
name: "Caches token to custom location",
tokenPath: t.TempDir() + "/tmp/test-token",
},
{
// Writing this file will fail but there should be no error returned from OidcAuthenticate()
name: "Caches token to custom location with trailing slash",
tokenPath: t.TempDir() + "/tmp/test-token/",
expectFileWriteError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ts, client := setupTestOidcClient(tc.tokenPath)
defer ts.Close()

token, err := client.OidcAuthenticate("code", "nonce", "code-verifier")

assert.NoError(t, err)
assert.Equal(t, "test-token", string(token))

if tc.expectFileWriteError {
// File writing should have failed, so the token should not have been cached
// We just want to test that there was no error returned from OidcAuthenticate()
return
}

// Check that token was cached to the correct location
var tokenPath string
if tc.tokenPath == "" {
tokenPath = defaultOidcTokenPath
} else {
tokenPath = tc.tokenPath
}

// Check file permissions
fileInfo, err := os.Stat(tokenPath)
assert.NoError(t, err)
assert.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm())

// Check file contents
tokenFile, err := os.Open(tokenPath)
assert.NoError(t, err)

tokenBytes, err := io.ReadAll(tokenFile)
assert.NoError(t, err)
assert.Equal(t, "test-token", string(tokenBytes))

// Cleanup
os.Remove(tokenPath)
})
}
}

func setupTestOidcClient(tokenPath string) (*httptest.Server, *Client) {
mockConjurServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Listen for the authenticate endpoint and return a test token
if strings.HasSuffix(r.URL.Path, "/authn-oidc/test-provider/cucumber/authenticate") {
w.WriteHeader(http.StatusOK)
w.Write([]byte("test-token"))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))

client := &Client{
config: Config{
Account: "cucumber",
ApplianceURL: mockConjurServer.URL,
AuthnType: "oidc",
ServiceID: "test-provider",
OidcTokenPath: tokenPath,
},
httpClient: &http.Client{},
}

return mockConjurServer, client
}
56 changes: 54 additions & 2 deletions conjurapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ func NewClientFromKey(config Config, loginPair authn.LoginPair) (*Client, error)
return client, err
}

func NewClientFromOidcCode(config Config, code, nonce, code_verifier string) (*Client, error) {
authenticator := &authn.OidcAuthenticator{
Code: code,
Nonce: nonce,
CodeVerifier: code_verifier,
}
client, err := newClientWithAuthenticator(
config,
authenticator,
)
if err == nil {
authenticator.Authenticate = client.OidcAuthenticate
}
return client, err
}

// ReadResponseBody fully reads a response and closes it.
func ReadResponseBody(response io.ReadCloser) ([]byte, error) {
defer response.Close()
Expand Down Expand Up @@ -175,6 +191,19 @@ func NewClientFromEnvironment(config Config) (*Client, error) {
return NewClientFromKey(config, *loginPair)
}

if config.AuthnType == "oidc" {
client, err := NewClientFromOidcCode(config, "", "", "")
if err != nil {
return nil, err
}
token := client.readCachedAccessToken()
if token != nil && !token.ShouldRefresh() {
return client, nil
}

return nil, fmt.Errorf("No valid OIDC token found. Please login again.")
}

return nil, fmt.Errorf("Environment variables and machine identity files satisfying at least one authentication strategy must be present!")
}

Expand Down Expand Up @@ -242,6 +271,21 @@ func (c *Client) AuthenticateRequest(loginPair authn.LoginPair) (*http.Request,
return req, nil
}

func (c *Client) ListOidcProvidersRequest() (*http.Request, error) {
return http.NewRequest("GET", c.oidcProvidersUrl(), nil)
}

func (c *Client) OidcAuthenticateRequest(code, nonce, code_verifier string) (*http.Request, error) {
authenticateURL := makeRouterURL(c.authnURL(), "authenticate").withFormattedQuery("code=%s&nonce=%s&code_verifier=%s", code, nonce, code_verifier).String()

req, err := http.NewRequest("GET", authenticateURL, nil)
if err != nil {
return nil, err
}

return req, nil
}

func (c *Client) RotateAPIKeyRequest(roleID string) (*http.Request, error) {
account, _, _, err := parseID(roleID)
if err != nil {
Expand Down Expand Up @@ -444,12 +488,20 @@ func (c *Client) batchVariableURL(variableIDs []string) string {
}

func (c *Client) authnURL() string {
if c.config.AuthnType == "ldap" {
return makeRouterURL(c.config.ApplianceURL, "authn-ldap", c.config.ServiceID, c.config.Account).String()
if c.config.AuthnType != "" && c.config.AuthnType != "authn" {
// If using an alternate authn service, such as authn-oidc, the URL will be
// '/authn-<type>/<service-id>/<account>'
authnType := fmt.Sprintf("authn-%s", c.config.AuthnType)
return makeRouterURL(c.config.ApplianceURL, authnType, c.config.ServiceID, c.config.Account).String()
}
// For the default authn service, the URL will be '/authn/<account>'
return makeRouterURL(c.config.ApplianceURL, "authn", c.config.Account).String()
}

func (c *Client) oidcProvidersUrl() string {
return makeRouterURL(c.config.ApplianceURL, "authn-oidc", c.config.Account, "providers").String()
}

func (c *Client) resourcesURL(account string) string {
return makeRouterURL(c.config.ApplianceURL, "resources", account).String()
}
Expand Down
10 changes: 10 additions & 0 deletions conjurapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,13 @@ func TestNewClientFromToken(t *testing.T) {
assert.IsType(t, &authn.TokenAuthenticator{}, client.authenticator)
})
}

func TestNewClientFromOidcCode(t *testing.T) {
t.Run("Has authenticator of type OidcAuthenticator", func(t *testing.T) {
config := Config{ServiceID: "test", AuthnType: "oidc", Account: "account", ApplianceURL: "appliance-url"}
client, err := NewClientFromOidcCode(config, "test-code", "test-nonce", "test-code-verifier")

assert.NoError(t, err)
assert.IsType(t, &authn.OidcAuthenticator{}, client.authenticator)
})
}
Loading

0 comments on commit 873394e

Please sign in to comment.