Skip to content

feat: add azure oidc PKI auth instead of client secret #9054

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

Merged
merged 15 commits into from
Aug 14, 2023
Merged
Prev Previous commit
Next Next commit
add client cert and key as deployment options
  • Loading branch information
Emyrk committed Aug 11, 2023
commit c62e5485aca1dbd046ecb6c60acd91a85b22ec85
39 changes: 33 additions & 6 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import (
"sync/atomic"
"time"

"github.com/coder/coder/coderd/azureauth"
"github.com/coder/coder/coderd/oauthpki"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-systemd/daemon"
Expand Down Expand Up @@ -553,7 +553,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
}

if true || cfg.OIDC.ClientSecret != "" {
if cfg.OIDC.ClientKeyFile != "" || cfg.OIDC.ClientSecret != "" {
if cfg.OIDC.ClientID == "" {
return xerrors.Errorf("OIDC client ID be set!")
}
Expand Down Expand Up @@ -587,12 +587,21 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
Endpoint: oidcProvider.Endpoint(),
Scopes: cfg.OIDC.Scopes,
}
jwtCfg, err := azureauth.NewJWTAssertion(oauthCfg)
if err != nil {
return xerrors.Errorf("configure azure auth: %w", err)

var useCfg httpmw.OAuth2Config = oauthCfg
if cfg.OIDC.ClientKeyFile != "" {
if cfg.OIDC.ClientSecret != "" {
return xerrors.Errorf("cannot specify both oidc client secret and oidc client key file")
}

pkiCfg, err := configureOIDCPKI(oauthCfg, cfg.OIDC.ClientKeyFile.Value(), cfg.OIDC.ClientCertFile.Value())
if err != nil {
return xerrors.Errorf("configure oauth pki authentication: %w", err)
}
useCfg = pkiCfg
}
options.OIDCConfig = &coderd.OIDCConfig{
OAuth2Config: jwtCfg,
OAuth2Config: useCfg,
Provider: oidcProvider,
Verifier: oidcProvider.Verifier(&oidc.Config{
ClientID: cfg.OIDC.ClientID.String(),
Expand Down Expand Up @@ -1501,6 +1510,24 @@ func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles
return tlsConfig, nil
}

func configureOIDCPKI(orig *oauth2.Config, keyFile string, certFile string) (*oauthpki.Config, error) {
// Read the files
keyData, err := os.ReadFile(keyFile)
if err != nil {
return nil, xerrors.Errorf("read oidc client key file: %w", err)
}

var certData []byte
if certFile != "" {
certData, err = os.ReadFile(certFile)
if err != nil {
return nil, xerrors.Errorf("read oidc client cert file: %w", err)
}
}

return oauthpki.NewOauth2PKIConfig(orig, keyData, certData)
}

func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
if tlsClientCAFile != "" {
caPool := x509.NewCertPool()
Expand Down
94 changes: 0 additions & 94 deletions coderd/azureauth/oidcpki.go

This file was deleted.

115 changes: 115 additions & 0 deletions coderd/oauthpki/oidcpki.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package oauthpki

import (
"context"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"time"

"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
)

// Config uses jwt assertions over client_secret for oauth2 authentication of
// the application. This implementation was made specifically for Azure AD.
//
// https://learn.microsoft.com/en-us/azure/active-directory/develop/certificate-credentials
//
// However this does mostly follow the standard. We can generalize this as we
// include support for more IDPs.
//
// https://datatracker.ietf.org/doc/html/rfc7523
type Config struct {
*oauth2.Config

// ClientSecret is the private key of the PKI cert.
// Azure AD only supports RS256 signing algorithm.
clientKey *rsa.PrivateKey
// Base64url-encoded SHA-1 thumbprint of the X.509 certificate's DER encoding.
// This is specific to Azure AD
x5t string
}

// NewOauth2PKIConfig creates the oauth2 config for PKI based auth. It requires the certificate and it's private key.
// The values should be passed in as PEM encoded values, which is the standard encoding for x509 certs saved to disk.
// It should look like:
//
// -----BEGIN RSA PRIVATE KEY----
// ...
// -----END RSA PRIVATE KEY-----
//
// -----BEGIN CERTIFICATE-----
// ...
// -----END CERTIFICATE-----
func NewOauth2PKIConfig(config *oauth2.Config, pemEncodedKey []byte, pemEncodedCert []byte) (*Config, error) {
rsaKey, err := decodeKeyCertificate(pemEncodedKey)
if err != nil {
return nil, err
}

// Azure AD requires a certificate. The sha1 of the cert is used to identify the signer.
// This is not required in the general specification.
if strings.Contains(strings.ToLower(config.Endpoint.TokenURL), "microsoftonline") && len(pemEncodedCert) == 0 {
return nil, xerrors.Errorf("oidc client certificate is required and missing")
}

block, _ := pem.Decode(pemEncodedCert)
hashed := sha1.Sum(block.Bytes)

return &Config{
Config: config,
clientKey: rsaKey,
x5t: base64.StdEncoding.EncodeToString(hashed[:]),
}, nil
}

// decodeKeyCertificate decodes a PEM encoded PKI cert.
func decodeKeyCertificate(pemEncoded []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemEncoded)
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, xerrors.Errorf("failed to parse private key: %w", err)
}

return key, nil
}

func (ja *Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return ja.Config.AuthCodeURL(state, opts...)
}

// Exchange includes the client_assertion signed JWT.
func (ja *Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": ja.Config.ClientID,
"sub": ja.Config.ClientID,
"aud": ja.Config.Endpoint.TokenURL,
"exp": now.Add(time.Minute * 5).Unix(),
"jti": uuid.New().String(),
"nbf": now.Unix(),
"iat": now.Unix(),
})
token.Header["x5t"] = ja.x5t

signed, err := token.SignedString(ja.clientKey)
if err != nil {
return nil, xerrors.Errorf("failed to sign jwt assertion: %w", err)
}

opts = append(opts,
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
oauth2.SetAuthURLParam("client_assertion", signed),
)
return ja.Config.Exchange(ctx, code, opts...)
}

func (ja *Config) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
return ja.Config.TokenSource(ctx, token)
}
38 changes: 24 additions & 14 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,12 @@ type OAuth2GithubConfig struct {
}

type OIDCConfig struct {
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
ClientID clibase.String `json:"client_id" typescript:",notnull"`
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
ClientID clibase.String `json:"client_id" typescript:",notnull"`
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
// ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth.
ClientKeyFile clibase.String `json:"client_key_file" typescript:",notnull"`
ClientCertFile clibase.String `json:"client_cert_file" typescript:",notnull"`
EmailDomain clibase.StringArray `json:"email_domain" typescript:",notnull"`
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
Scopes clibase.StringArray `json:"scopes" typescript:",notnull"`
Expand All @@ -280,17 +283,6 @@ type OIDCConfig struct {
UserRolesDefault clibase.StringArray `json:"user_roles_default" typescript:",notnull"`
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
// OIDC Provider Specific Configurations
// Ideally all OIDC providers would follow all the same standards, but in practice they
// all have unique quirks.

}

// AzureADOIDCConfig is the configuration for Azure AD OIDC IDP.
// Try at all costs to keep configuration in the generic OIDCConfig struct above.
// Only use this if you absolutely need to.
type AzureADOIDCConfig struct {
Certificate clibase.String
}

type TelemetryConfig struct {
Expand Down Expand Up @@ -977,6 +969,24 @@ when required by your organization's security policy.`,
Value: &c.OIDC.ClientSecret,
Group: &deploymentGroupOIDC,
},
{
Name: "OIDC Client Key File",
Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " +
"This can be used instead of oidc-client-secret if your IDP supports it.",
Flag: "oidc-client-key-file",
Env: "CODER_OIDC_CLIENT_KEY_FILE",
Value: &c.OIDC.ClientKeyFile,
Group: &deploymentGroupOIDC,
},
{
Name: "OIDC Client Cert File",
Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " +
"The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.",
Flag: "oidc-client-cert-file",
Env: "CODER_OIDC_CLIENT_CERT_FILE",
Value: &c.OIDC.ClientCertFile,
Group: &deploymentGroupOIDC,
},
{
Name: "OIDC Email Domain",
Description: "Email domains that clients logging in with OIDC must match.",
Expand Down
7 changes: 2 additions & 5 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.