Skip to content

Commit 8ba23b1

Browse files
committed
feat: add azure oidc PKI auth instead of client secret
1 parent 091c00b commit 8ba23b1

File tree

4 files changed

+126
-9
lines changed

4 files changed

+126
-9
lines changed

cli/server.go

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

36+
"github.com/coder/coder/coderd/azureauth"
37+
3638
"github.com/coreos/go-oidc/v3/oidc"
3739
"github.com/coreos/go-systemd/daemon"
3840
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
@@ -551,7 +553,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
551553
}
552554
}
553555

554-
if cfg.OIDC.ClientSecret != "" {
556+
if true || cfg.OIDC.ClientSecret != "" {
555557
if cfg.OIDC.ClientID == "" {
556558
return xerrors.Errorf("OIDC client ID be set!")
557559
}
@@ -578,15 +580,20 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
578580
if slice.Contains(cfg.OIDC.Scopes, "groups") && cfg.OIDC.GroupField == "" {
579581
cfg.OIDC.GroupField = "groups"
580582
}
583+
oauthCfg := &oauth2.Config{
584+
ClientID: cfg.OIDC.ClientID.String(),
585+
ClientSecret: cfg.OIDC.ClientSecret.String(),
586+
RedirectURL: redirectURL.String(),
587+
Endpoint: oidcProvider.Endpoint(),
588+
Scopes: cfg.OIDC.Scopes,
589+
}
590+
jwtCfg, err := azureauth.NewJWTAssertion(oauthCfg)
591+
if err != nil {
592+
return xerrors.Errorf("configure azure auth: %w", err)
593+
}
581594
options.OIDCConfig = &coderd.OIDCConfig{
582-
OAuth2Config: &oauth2.Config{
583-
ClientID: cfg.OIDC.ClientID.String(),
584-
ClientSecret: cfg.OIDC.ClientSecret.String(),
585-
RedirectURL: redirectURL.String(),
586-
Endpoint: oidcProvider.Endpoint(),
587-
Scopes: cfg.OIDC.Scopes,
588-
},
589-
Provider: oidcProvider,
595+
OAuth2Config: jwtCfg,
596+
Provider: oidcProvider,
590597
Verifier: oidcProvider.Verifier(&oidc.Config{
591598
ClientID: cfg.OIDC.ClientID.String(),
592599
}),

coderd/azureauth/oidcpki.go

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package azureauth
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"crypto/sha1"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"encoding/pem"
10+
"time"
11+
12+
"github.com/google/uuid"
13+
14+
"github.com/golang-jwt/jwt/v4"
15+
16+
"golang.org/x/xerrors"
17+
18+
"golang.org/x/oauth2"
19+
)
20+
21+
// JWTAssertion is used by Azure AD when doing OIDC with a PKI cert instead of
22+
// a client secret.
23+
// https://learn.microsoft.com/en-us/azure/active-directory/develop/certificate-credentials
24+
type JWTAssertion struct {
25+
*oauth2.Config
26+
27+
// ClientSecret is the private key of the PKI cert.
28+
// Azure AD only supports RS256 signing algorithm.
29+
clientKey *rsa.PrivateKey
30+
// Base64url-encoded SHA-1 thumbprint of the X.509 certificate's DER encoding.
31+
x5t string
32+
}
33+
34+
func NewJWTAssertion(config *oauth2.Config, pemEncodedKey string, pemEncodedCert string) (*JWTAssertion, error) {
35+
rsaKey, err := DecodeKeyCertificate([]byte(pemEncodedKey))
36+
if err != nil {
37+
return nil, err
38+
}
39+
40+
block, _ := pem.Decode([]byte(pemEncodedCert))
41+
hashed := sha1.Sum(block.Bytes)
42+
43+
return &JWTAssertion{
44+
Config: config,
45+
clientKey: rsaKey,
46+
x5t: base64.StdEncoding.EncodeToString(hashed[:]),
47+
}, nil
48+
}
49+
50+
// DecodeKeyCertificate decodes a PEM encoded PKI cert.
51+
func DecodeKeyCertificate(pemEncoded []byte) (*rsa.PrivateKey, error) {
52+
block, _ := pem.Decode(pemEncoded)
53+
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
54+
if err != nil {
55+
return nil, xerrors.Errorf("failed to parse private key: %w", err)
56+
}
57+
58+
return key, nil
59+
}
60+
61+
func (ja *JWTAssertion) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
62+
return ja.Config.AuthCodeURL(state, opts...)
63+
}
64+
65+
func (ja *JWTAssertion) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
66+
now := time.Now()
67+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
68+
"aud": ja.Config.Endpoint.TokenURL,
69+
"exp": now.Add(time.Minute * 5).Unix(),
70+
"jti": uuid.New().String(),
71+
"nbf": now.Unix(),
72+
"iat": now.Unix(),
73+
74+
// TODO: Should be app GUID, not client ID.
75+
"iss": ja.Config.ClientID,
76+
"sub": ja.Config.ClientID,
77+
})
78+
token.Header["x5t"] = ja.x5t
79+
80+
signed, err := token.SignedString(ja.clientKey)
81+
if err != nil {
82+
return nil, xerrors.Errorf("failed to sign jwt assertion: %w", err)
83+
}
84+
85+
opts = append(opts,
86+
oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
87+
oauth2.SetAuthURLParam("client_assertion", signed),
88+
)
89+
return ja.Config.Exchange(ctx, code, opts...)
90+
}
91+
92+
func (ja *JWTAssertion) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
93+
return ja.Config.TokenSource(ctx, token)
94+
}

codersdk/deployment.go

+11
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ type OIDCConfig struct {
280280
UserRolesDefault clibase.StringArray `json:"user_roles_default" typescript:",notnull"`
281281
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
282282
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
283294
}
284295

285296
type TelemetryConfig struct {

site/src/api/typesGenerated.ts

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

0 commit comments

Comments
 (0)