Skip to content

Commit

Permalink
Merge pull request #471 from okta/OKTA-424161-add-jwk-authentication
Browse files Browse the repository at this point in the history
add jwk authentication
  • Loading branch information
duytiennguyen-okta authored Jun 28, 2024
2 parents 356a97a + c5bf39e commit e13c84a
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 4 deletions.
169 changes: 167 additions & 2 deletions .generator/templates/client.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,155 @@ func (a *JWTAuth) Authorize(method, URL string) error {
return nil
}

type JWKAuth struct {
tokenCache *goCache.Cache
httpClient *http.Client
jwk string
encryptionType string
privateKeySigner jose.Signer
privateKey string
privateKeyId string
clientId string
orgURL string
userAgent string
scopes []string
maxRetries int32
maxBackoff int64
req *http.Request
}

type JWKAuthConfig struct {
TokenCache *goCache.Cache
HttpClient *http.Client
JWK string
EncryptionType string
PrivateKeySigner jose.Signer
PrivateKeyId string
ClientId string
OrgURL string
UserAgent string
Scopes []string
MaxRetries int32
MaxBackoff int64
Req *http.Request
}

func NewJWKAuth(config JWKAuthConfig) *JWKAuth {
return &JWKAuth{
tokenCache: config.TokenCache,
httpClient: config.HttpClient,
jwk: config.JWK,
encryptionType: config.EncryptionType,
privateKeySigner: config.PrivateKeySigner,
privateKeyId: config.PrivateKeyId,
clientId: config.ClientId,
orgURL: config.OrgURL,
userAgent: config.UserAgent,
scopes: config.Scopes,
maxRetries: config.MaxRetries,
maxBackoff: config.MaxBackoff,
req: config.Req,
}
}

func (a *JWKAuth) Authorize(method, URL string) error {
accessToken, hasToken := a.tokenCache.Get(AccessTokenCacheKey)
if hasToken && accessToken != "" {
accessTokenWithTokenType := accessToken.(string)
a.req.Header.Add("Authorization", accessTokenWithTokenType)
nonce, hasNonce := a.tokenCache.Get(DpopAccessTokenNonce)
if hasNonce && nonce != "" {
privateKey, ok := a.tokenCache.Get(DpopAccessTokenPrivateKey)
if ok && privateKey != nil {
res := strings.Split(accessTokenWithTokenType, " ")
if len(res) != 2 {
return errors.New("Unidentified access token")
}
dpopJWT, err := generateDpopJWT(privateKey.(*rsa.PrivateKey), method, URL, nonce.(string), res[1])
if err != nil {
return err
}
a.req.Header.Set("Dpop", dpopJWT)
a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true")
} else {
return errors.New("Using Dpop but signing key not found")
}
}
} else {
privateKey, err := convertJWKToPrivateKey(a.jwk, a.encryptionType)
if err != nil {
return err
}
if a.privateKeySigner == nil {
var err error
a.privateKeySigner, err = createKeySigner(privateKey, a.privateKeyId)
if err != nil {
return err
}
}

clientAssertion, err := createClientAssertion(a.orgURL, a.clientId, a.privateKeySigner)
if err != nil {
return err
}

accessToken, nonce, dpopPrivateKey, err := getAccessTokenForPrivateKey(a.httpClient, a.orgURL, clientAssertion, a.userAgent, a.scopes, a.maxRetries, a.maxBackoff)
if err != nil {
return err
}

if accessToken == nil {
return errors.New("Empty access token")
}

a.req.Header.Set("Authorization", fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken))
if accessToken.TokenType == "DPoP" {
dpopJWT, err := generateDpopJWT(dpopPrivateKey, method, URL, nonce, accessToken.AccessToken)
if err != nil {
return err
}
a.req.Header.Set("Dpop", dpopJWT)
a.req.Header.Set("x-okta-user-agent-extended", "isDPoP:true")
}

// Trim a couple of seconds off calculated expiry so cache expiry
// occures before Okta server side expiry.
expiration := accessToken.ExpiresIn - 2
a.tokenCache.Set(AccessTokenCacheKey, fmt.Sprintf("%v %v", accessToken.TokenType, accessToken.AccessToken), time.Second*time.Duration(expiration))
a.tokenCache.Set(DpopAccessTokenNonce, nonce, time.Second*time.Duration(expiration))
a.tokenCache.Set(DpopAccessTokenPrivateKey, dpopPrivateKey, time.Second*time.Duration(expiration))
}
return nil
}

func convertJWKToPrivateKey(jwks, encryptionType string) (string, error) {
set, err := jwk.Parse([]byte(jwks))
if err != nil {
return "", err
}
for it := set.Iterate(context.Background()); it.Next(context.Background()); {
pair := it.Pair()
key := pair.Value.(jwk.Key)
var rawkey interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey
err := key.Raw(&rawkey);
if err != nil {
return "",err
}

switch encryptionType {
case "RSA":
rsaPrivateKey, ok := rawkey.(*rsa.PrivateKey)
if !ok {
return "",fmt.Errorf("expected rsa key, got %T", rawkey)
}
return string(privateKeyToBytes(rsaPrivateKey)), nil
default:
return "", fmt.Errorf("unknown encryptionType %v", encryptionType)
}
}
return "", fmt.Errorf("unknown encryptionType %v", encryptionType)
}

func createKeySigner(privateKey, privateKeyID string) (jose.Signer, error) {
var signerOptions *jose.SignerOptions
if privateKeyID != "" {
Expand Down Expand Up @@ -839,7 +988,7 @@ func (c *APIClient) prepareRequest(
PrivateKeyId: c.cfg.Okta.Client.PrivateKeyId,
ClientId: c.cfg.Okta.Client.ClientId,
OrgURL: c.cfg.Okta.Client.OrgUrl,
UserAgent: NewUserAgent(c.cfg).String(),
UserAgent: NewUserAgent(c.cfg).String(),
Scopes: c.cfg.Okta.Client.Scopes,
MaxRetries: c.cfg.Okta.Client.RateLimit.MaxRetries,
MaxBackoff: c.cfg.Okta.Client.RateLimit.MaxBackoff,
Expand All @@ -850,13 +999,29 @@ func (c *APIClient) prepareRequest(
TokenCache: c.tokenCache,
HttpClient: c.cfg.HTTPClient,
OrgURL: c.cfg.Okta.Client.OrgUrl,
UserAgent: NewUserAgent(c.cfg).String(),
UserAgent: NewUserAgent(c.cfg).String(),
Scopes: c.cfg.Okta.Client.Scopes,
ClientAssertion: c.cfg.Okta.Client.ClientAssertion,
MaxRetries: c.cfg.Okta.Client.RateLimit.MaxRetries,
MaxBackoff: c.cfg.Okta.Client.RateLimit.MaxBackoff,
Req: localVarRequest,
})
case "JWK":
auth = NewJWKAuth(JWKAuthConfig{
TokenCache: c.tokenCache,
HttpClient: c.cfg.HTTPClient,
JWK: c.cfg.Okta.Client.JWK,
EncryptionType: c.cfg.Okta.Client.EncryptionType,
PrivateKeySigner: c.cfg.PrivateKeySigner,
PrivateKeyId: c.cfg.Okta.Client.PrivateKeyId,
ClientId: c.cfg.Okta.Client.ClientId,
OrgURL: c.cfg.Okta.Client.OrgUrl,
UserAgent: NewUserAgent(c.cfg).String(),
Scopes: c.cfg.Okta.Client.Scopes,
MaxRetries: c.cfg.Okta.Client.RateLimit.MaxRetries,
MaxBackoff: c.cfg.Okta.Client.RateLimit.MaxBackoff,
Req: localVarRequest,
})
default:
return nil, fmt.Errorf("unknown authorization mode %v", c.cfg.Okta.Client.AuthorizationMode)
}
Expand Down
14 changes: 14 additions & 0 deletions .generator/templates/configuration.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ type Configuration struct {
Scopes []string `yaml:"scopes" envconfig:"OKTA_CLIENT_SCOPES"`
PrivateKey string `yaml:"privateKey" envconfig:"OKTA_CLIENT_PRIVATEKEY"`
PrivateKeyId string `yaml:"privateKeyId" envconfig:"OKTA_CLIENT_PRIVATEKEYID"`
JWK string `yaml:"jwk" envconfig:"OKTA_CLIENT_JWK"`
EncryptionType string `yaml:"encryptionType" envconfig:"OKTA_CLIENT_ENCRYPTION_TYPE"`
} `yaml:"client"`
Testing struct {
DisableHttpsCheck bool `yaml:"disableHttpsCheck" envconfig:"OKTA_TESTING_DISABLE_HTTPS_CHECK"`
Expand Down Expand Up @@ -572,6 +574,18 @@ func WithDebug(debug bool) ConfigSetter {
}
}

func WithJWK(jwk string) ConfigSetter {
return func(c *Configuration) {
c.Okta.Client.JWK = jwk
}
}

func WithEncryptionType(etype string) ConfigSetter {
return func(c *Configuration) {
c.Okta.Client.EncryptionType = etype
}
}

// WithPrivateKey sets private key key. Can be either a path to a private key or private key itself.
func WithPrivateKey(privateKey string) ConfigSetter {
return func(c *Configuration) {
Expand Down
15 changes: 15 additions & 0 deletions .generator/templates/private_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ func Test_JWT_Request_Can_Create_User(t *testing.T) {
assert.NotNil(t, user, "User should not be nil")
}

func Test_JWK_Request_Can_Create_User(t *testing.T) {
if os.Getenv("OKTA_CCI") != "yes" {
t.Skip("Skipping testing not in CI environment")
}
configuration, err := NewConfiguration(WithAuthorizationMode("JWK"), WithScopes([]string{"okta.users.manage"}), WithJWK(""), WithEncryptionType("RSA"))
require.NoError(t, err, "Creating a new config should not error")
client := NewAPIClient(configuration)
uc := testFactory.NewValidTestUserCredentialsWithPassword()
profile := testFactory.NewValidTestUserProfile()
body := CreateUserRequest{Credentials: uc, Profile: profile}
user, _, err := client.UserAPI.CreateUser(apiClient.cfg.Context).Body(body).Execute()
require.NoError(t, err, "Creating a new user should not error")
assert.NotNil(t, user, "User should not be nil")
}

func Test_Dpop_Get_User(t *testing.T) {
configuration, err := NewConfiguration(WithAuthorizationMode("PrivateKey"), WithScopes([]string{"okta.users.manage", "okta.users.read"}))
require.NoError(t, err, "Creating a new config should not error")
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ jobs:
name: Run GoReleaser
uses: goreleaser/[email protected]
with:
version: latest
args: release --clean
version: "v1.25.1"
args: release --rm-dist
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
# GitHub sets this automatically
Expand Down

0 comments on commit e13c84a

Please sign in to comment.