Skip to content

Commit c62e548

Browse files
committed
add client cert and key as deployment options
1 parent 8ba23b1 commit c62e548

File tree

5 files changed

+174
-119
lines changed

5 files changed

+174
-119
lines changed

cli/server.go

+33-6
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
"sync/atomic"
3434
"time"
3535

36-
"github.com/coder/coder/coderd/azureauth"
36+
"github.com/coder/coder/coderd/oauthpki"
3737

3838
"github.com/coreos/go-oidc/v3/oidc"
3939
"github.com/coreos/go-systemd/daemon"
@@ -553,7 +553,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
553553
}
554554
}
555555

556-
if true || cfg.OIDC.ClientSecret != "" {
556+
if cfg.OIDC.ClientKeyFile != "" || cfg.OIDC.ClientSecret != "" {
557557
if cfg.OIDC.ClientID == "" {
558558
return xerrors.Errorf("OIDC client ID be set!")
559559
}
@@ -587,12 +587,21 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
587587
Endpoint: oidcProvider.Endpoint(),
588588
Scopes: cfg.OIDC.Scopes,
589589
}
590-
jwtCfg, err := azureauth.NewJWTAssertion(oauthCfg)
591-
if err != nil {
592-
return xerrors.Errorf("configure azure auth: %w", err)
590+
591+
var useCfg httpmw.OAuth2Config = oauthCfg
592+
if cfg.OIDC.ClientKeyFile != "" {
593+
if cfg.OIDC.ClientSecret != "" {
594+
return xerrors.Errorf("cannot specify both oidc client secret and oidc client key file")
595+
}
596+
597+
pkiCfg, err := configureOIDCPKI(oauthCfg, cfg.OIDC.ClientKeyFile.Value(), cfg.OIDC.ClientCertFile.Value())
598+
if err != nil {
599+
return xerrors.Errorf("configure oauth pki authentication: %w", err)
600+
}
601+
useCfg = pkiCfg
593602
}
594603
options.OIDCConfig = &coderd.OIDCConfig{
595-
OAuth2Config: jwtCfg,
604+
OAuth2Config: useCfg,
596605
Provider: oidcProvider,
597606
Verifier: oidcProvider.Verifier(&oidc.Config{
598607
ClientID: cfg.OIDC.ClientID.String(),
@@ -1501,6 +1510,24 @@ func configureTLS(tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles
15011510
return tlsConfig, nil
15021511
}
15031512

1513+
func configureOIDCPKI(orig *oauth2.Config, keyFile string, certFile string) (*oauthpki.Config, error) {
1514+
// Read the files
1515+
keyData, err := os.ReadFile(keyFile)
1516+
if err != nil {
1517+
return nil, xerrors.Errorf("read oidc client key file: %w", err)
1518+
}
1519+
1520+
var certData []byte
1521+
if certFile != "" {
1522+
certData, err = os.ReadFile(certFile)
1523+
if err != nil {
1524+
return nil, xerrors.Errorf("read oidc client cert file: %w", err)
1525+
}
1526+
}
1527+
1528+
return oauthpki.NewOauth2PKIConfig(orig, keyData, certData)
1529+
}
1530+
15041531
func configureCAPool(tlsClientCAFile string, tlsConfig *tls.Config) error {
15051532
if tlsClientCAFile != "" {
15061533
caPool := x509.NewCertPool()

coderd/azureauth/oidcpki.go

-94
This file was deleted.

coderd/oauthpki/oidcpki.go

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package oauthpki
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"crypto/sha1"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"encoding/pem"
10+
"strings"
11+
"time"
12+
13+
"github.com/golang-jwt/jwt/v4"
14+
"github.com/google/uuid"
15+
"golang.org/x/oauth2"
16+
"golang.org/x/xerrors"
17+
)
18+
19+
// Config uses jwt assertions over client_secret for oauth2 authentication of
20+
// the application. This implementation was made specifically for Azure AD.
21+
//
22+
// https://learn.microsoft.com/en-us/azure/active-directory/develop/certificate-credentials
23+
//
24+
// However this does mostly follow the standard. We can generalize this as we
25+
// include support for more IDPs.
26+
//
27+
// https://datatracker.ietf.org/doc/html/rfc7523
28+
type Config struct {
29+
*oauth2.Config
30+
31+
// ClientSecret is the private key of the PKI cert.
32+
// Azure AD only supports RS256 signing algorithm.
33+
clientKey *rsa.PrivateKey
34+
// Base64url-encoded SHA-1 thumbprint of the X.509 certificate's DER encoding.
35+
// This is specific to Azure AD
36+
x5t string
37+
}
38+
39+
// NewOauth2PKIConfig creates the oauth2 config for PKI based auth. It requires the certificate and it's private key.
40+
// The values should be passed in as PEM encoded values, which is the standard encoding for x509 certs saved to disk.
41+
// It should look like:
42+
//
43+
// -----BEGIN RSA PRIVATE KEY----
44+
// ...
45+
// -----END RSA PRIVATE KEY-----
46+
//
47+
// -----BEGIN CERTIFICATE-----
48+
// ...
49+
// -----END CERTIFICATE-----
50+
func NewOauth2PKIConfig(config *oauth2.Config, pemEncodedKey []byte, pemEncodedCert []byte) (*Config, error) {
51+
rsaKey, err := decodeKeyCertificate(pemEncodedKey)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
// Azure AD requires a certificate. The sha1 of the cert is used to identify the signer.
57+
// This is not required in the general specification.
58+
if strings.Contains(strings.ToLower(config.Endpoint.TokenURL), "microsoftonline") && len(pemEncodedCert) == 0 {
59+
return nil, xerrors.Errorf("oidc client certificate is required and missing")
60+
}
61+
62+
block, _ := pem.Decode(pemEncodedCert)
63+
hashed := sha1.Sum(block.Bytes)
64+
65+
return &Config{
66+
Config: config,
67+
clientKey: rsaKey,
68+
x5t: base64.StdEncoding.EncodeToString(hashed[:]),
69+
}, nil
70+
}
71+
72+
// decodeKeyCertificate decodes a PEM encoded PKI cert.
73+
func decodeKeyCertificate(pemEncoded []byte) (*rsa.PrivateKey, error) {
74+
block, _ := pem.Decode(pemEncoded)
75+
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
76+
if err != nil {
77+
return nil, xerrors.Errorf("failed to parse private key: %w", err)
78+
}
79+
80+
return key, nil
81+
}
82+
83+
func (ja *Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
84+
return ja.Config.AuthCodeURL(state, opts...)
85+
}
86+
87+
// Exchange includes the client_assertion signed JWT.
88+
func (ja *Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
89+
now := time.Now()
90+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
91+
"iss": ja.Config.ClientID,
92+
"sub": ja.Config.ClientID,
93+
"aud": ja.Config.Endpoint.TokenURL,
94+
"exp": now.Add(time.Minute * 5).Unix(),
95+
"jti": uuid.New().String(),
96+
"nbf": now.Unix(),
97+
"iat": now.Unix(),
98+
})
99+
token.Header["x5t"] = ja.x5t
100+
101+
signed, err := token.SignedString(ja.clientKey)
102+
if err != nil {
103+
return nil, xerrors.Errorf("failed to sign jwt assertion: %w", err)
104+
}
105+
106+
opts = append(opts,
107+
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
108+
oauth2.SetAuthURLParam("client_assertion", signed),
109+
)
110+
return ja.Config.Exchange(ctx, code, opts...)
111+
}
112+
113+
func (ja *Config) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
114+
return ja.Config.TokenSource(ctx, token)
115+
}

codersdk/deployment.go

+24-14
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,12 @@ type OAuth2GithubConfig struct {
260260
}
261261

262262
type OIDCConfig struct {
263-
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
264-
ClientID clibase.String `json:"client_id" typescript:",notnull"`
265-
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
263+
AllowSignups clibase.Bool `json:"allow_signups" typescript:",notnull"`
264+
ClientID clibase.String `json:"client_id" typescript:",notnull"`
265+
ClientSecret clibase.String `json:"client_secret" typescript:",notnull"`
266+
// ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth.
267+
ClientKeyFile clibase.String `json:"client_key_file" typescript:",notnull"`
268+
ClientCertFile clibase.String `json:"client_cert_file" typescript:",notnull"`
266269
EmailDomain clibase.StringArray `json:"email_domain" typescript:",notnull"`
267270
IssuerURL clibase.String `json:"issuer_url" typescript:",notnull"`
268271
Scopes clibase.StringArray `json:"scopes" typescript:",notnull"`
@@ -280,17 +283,6 @@ type OIDCConfig struct {
280283
UserRolesDefault clibase.StringArray `json:"user_roles_default" typescript:",notnull"`
281284
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
282285
IconURL clibase.URL `json:"icon_url" typescript:",notnull"`
283-
// OIDC Provider Specific Configurations
284-
// Ideally all OIDC providers would follow all the same standards, but in practice they
285-
// all have unique quirks.
286-
287-
}
288-
289-
// AzureADOIDCConfig is the configuration for Azure AD OIDC IDP.
290-
// Try at all costs to keep configuration in the generic OIDCConfig struct above.
291-
// Only use this if you absolutely need to.
292-
type AzureADOIDCConfig struct {
293-
Certificate clibase.String
294286
}
295287

296288
type TelemetryConfig struct {
@@ -977,6 +969,24 @@ when required by your organization's security policy.`,
977969
Value: &c.OIDC.ClientSecret,
978970
Group: &deploymentGroupOIDC,
979971
},
972+
{
973+
Name: "OIDC Client Key File",
974+
Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " +
975+
"This can be used instead of oidc-client-secret if your IDP supports it.",
976+
Flag: "oidc-client-key-file",
977+
Env: "CODER_OIDC_CLIENT_KEY_FILE",
978+
Value: &c.OIDC.ClientKeyFile,
979+
Group: &deploymentGroupOIDC,
980+
},
981+
{
982+
Name: "OIDC Client Cert File",
983+
Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " +
984+
"The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.",
985+
Flag: "oidc-client-cert-file",
986+
Env: "CODER_OIDC_CLIENT_CERT_FILE",
987+
Value: &c.OIDC.ClientCertFile,
988+
Group: &deploymentGroupOIDC,
989+
},
980990
{
981991
Name: "OIDC Email Domain",
982992
Description: "Email domains that clients logging in with OIDC must match.",

site/src/api/typesGenerated.ts

+2-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)