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
Next Next commit
feat: add azure oidc PKI auth instead of client secret
  • Loading branch information
Emyrk committed Aug 11, 2023
commit 8ba23b1ebcc5bd64636a17fc047bbf06deba5ccd
25 changes: 16 additions & 9 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import (
"sync/atomic"
"time"

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

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

if cfg.OIDC.ClientSecret != "" {
if true || cfg.OIDC.ClientSecret != "" {
if cfg.OIDC.ClientID == "" {
return xerrors.Errorf("OIDC client ID be set!")
}
Expand All @@ -578,15 +580,20 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if slice.Contains(cfg.OIDC.Scopes, "groups") && cfg.OIDC.GroupField == "" {
cfg.OIDC.GroupField = "groups"
}
oauthCfg := &oauth2.Config{
ClientID: cfg.OIDC.ClientID.String(),
ClientSecret: cfg.OIDC.ClientSecret.String(),
RedirectURL: redirectURL.String(),
Endpoint: oidcProvider.Endpoint(),
Scopes: cfg.OIDC.Scopes,
}
jwtCfg, err := azureauth.NewJWTAssertion(oauthCfg)
if err != nil {
return xerrors.Errorf("configure azure auth: %w", err)
}
options.OIDCConfig = &coderd.OIDCConfig{
OAuth2Config: &oauth2.Config{
ClientID: cfg.OIDC.ClientID.String(),
ClientSecret: cfg.OIDC.ClientSecret.String(),
RedirectURL: redirectURL.String(),
Endpoint: oidcProvider.Endpoint(),
Scopes: cfg.OIDC.Scopes,
},
Provider: oidcProvider,
OAuth2Config: jwtCfg,
Provider: oidcProvider,
Verifier: oidcProvider.Verifier(&oidc.Config{
ClientID: cfg.OIDC.ClientID.String(),
}),
Expand Down
94 changes: 94 additions & 0 deletions coderd/azureauth/oidcpki.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package azureauth

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

"github.com/google/uuid"

"github.com/golang-jwt/jwt/v4"

"golang.org/x/xerrors"

"golang.org/x/oauth2"
)

// JWTAssertion is used by Azure AD when doing OIDC with a PKI cert instead of
// a client secret.
// https://learn.microsoft.com/en-us/azure/active-directory/develop/certificate-credentials
type JWTAssertion 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.
x5t string
}

func NewJWTAssertion(config *oauth2.Config, pemEncodedKey string, pemEncodedCert string) (*JWTAssertion, error) {
rsaKey, err := DecodeKeyCertificate([]byte(pemEncodedKey))
if err != nil {
return nil, err
}

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

return &JWTAssertion{
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 *JWTAssertion) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return ja.Config.AuthCodeURL(state, opts...)
}

func (ja *JWTAssertion) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"aud": ja.Config.Endpoint.TokenURL,
"exp": now.Add(time.Minute * 5).Unix(),
"jti": uuid.New().String(),
"nbf": now.Unix(),
"iat": now.Unix(),

// TODO: Should be app GUID, not client ID.
"iss": ja.Config.ClientID,
"sub": ja.Config.ClientID,
})
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 *JWTAssertion) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
return ja.Config.TokenSource(ctx, token)
}
11 changes: 11 additions & 0 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,17 @@ 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
5 changes: 5 additions & 0 deletions site/src/api/typesGenerated.ts

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