Skip to content

Commit 028064c

Browse files
feat(sdk): make enforcement of DPoP optional (#617)
* add `auth.enforceDPoP`. enabling this setting tells the auth middleware to accept tokens that do not have a `cnf` claim as valid. since having DPoP or not is a property this config goes along with each issuer * change the return of `checkToken` to return a context that has the right stuff in it so that we don't have to validate that something that came back without an error is non-nil * make it possible to disable auth on rewrap. currently we can't because we require there be a dpop JWK addresses #566 --------- Co-authored-by: Dave Mihalcik <[email protected]>
1 parent 6ae7999 commit 028064c

File tree

7 files changed

+115
-59
lines changed

7 files changed

+115
-59
lines changed

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The server configuration is used to define how the application runs its server.
4141
| `tls.key` | The path to the tls key. | |
4242
| `auth.audience` | The audience for the IDP. | |
4343
| `auth.issuer` | The issuer for the IDP. | |
44+
| `auth.enforceDPoP` | If false, we allow access tokens that do not have DPoP bindings. | `true` |
4445

4546
Example:
4647

service/internal/auth/authn.go

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const refreshInterval = 15 * time.Minute
5858

5959
// Authentication holds a jwks cache and information about the openid configuration
6060
type Authentication struct {
61+
enforceDPoP bool
6162
// cache holds the jwks cache
6263
cache *jwk.Cache
6364
// openidConfigurations holds the openid configuration for each issuer
@@ -70,7 +71,9 @@ type Authentication struct {
7071

7172
// Creates new authN which is used to verify tokens for a set of given issuers
7273
func NewAuthenticator(ctx context.Context, cfg Config, d *db.Client) (*Authentication, error) {
73-
a := &Authentication{}
74+
a := &Authentication{
75+
enforceDPoP: cfg.EnforceDPoP,
76+
}
7477
a.oidcConfigurations = make(map[string]AuthNConfig)
7578

7679
// validate the configuration
@@ -146,7 +149,7 @@ func (a Authentication) MuxHandler(handler http.Handler) http.Handler {
146149
http.Error(w, "missing authorization header", http.StatusUnauthorized)
147150
return
148151
}
149-
tok, dpopKey, err := a.checkToken(r.Context(), header, dpopInfo{
152+
tok, newCtx, err := a.checkToken(r.Context(), header, dpopInfo{
150153
headers: r.Header["Dpop"],
151154
path: r.URL.Path,
152155
method: r.Method,
@@ -184,7 +187,7 @@ func (a Authentication) MuxHandler(handler http.Handler) http.Handler {
184187
return
185188
}
186189

187-
handler.ServeHTTP(w, r.WithContext(ContextWithJWK(r.Context(), dpopKey)))
190+
handler.ServeHTTP(w, r.WithContext(newCtx))
188191
})
189192
}
190193

@@ -225,7 +228,7 @@ func (a Authentication) UnaryServerInterceptor(ctx context.Context, req any, inf
225228
action = "other"
226229
}
227230

228-
token, dpopJWK, err := a.checkToken(
231+
token, newCtx, err := a.checkToken(
229232
ctx,
230233
header,
231234
dpopInfo{
@@ -251,26 +254,20 @@ func (a Authentication) UnaryServerInterceptor(ctx context.Context, req any, inf
251254
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
252255
}
253256

254-
// add dpop key to context
255-
ctx = ContextWithJWK(ctx, dpopJWK)
256-
257-
return handler(ctx, req)
257+
return handler(newCtx, req)
258258
}
259259

260260
// checkToken is a helper function to verify the token.
261-
func (a Authentication) checkToken(ctx context.Context, authHeader []string, dpopInfo dpopInfo) (jwt.Token, jwk.Key, error) {
261+
func (a Authentication) checkToken(ctx context.Context, authHeader []string, dpopInfo dpopInfo) (jwt.Token, context.Context, error) {
262262
var (
263-
tokenRaw string
264-
tokenType string
263+
tokenRaw string
265264
)
266265

267266
// If we don't get a DPoP/Bearer token type, we can't proceed
268267
switch {
269268
case strings.HasPrefix(authHeader[0], "DPoP "):
270-
tokenType = "DPoP"
271269
tokenRaw = strings.TrimPrefix(authHeader[0], "DPoP ")
272270
case strings.HasPrefix(authHeader[0], "Bearer "):
273-
tokenType = "Bearer"
274271
tokenRaw = strings.TrimPrefix(authHeader[0], "Bearer ")
275272
default:
276273
return nil, nil, fmt.Errorf("not of type bearer or dpop")
@@ -314,16 +311,17 @@ func (a Authentication) checkToken(ctx context.Context, authHeader []string, dpo
314311
return nil, nil, err
315312
}
316313

317-
if tokenType == "Bearer" {
318-
slog.Warn("Presented bearer token. validating as DPoP")
314+
_, tokenHasCNF := accessToken.Get("cnf")
315+
if !tokenHasCNF && a.enforceDPoP {
316+
// this condition is not quite tight because it's possible that the `cnf` claim may
317+
// come from token introspection
318+
return accessToken, ctx, nil
319319
}
320-
321320
key, err := validateDPoP(accessToken, tokenRaw, dpopInfo)
322321
if err != nil {
323322
return nil, nil, err
324323
}
325-
326-
return accessToken, *key, nil
324+
return accessToken, ContextWithJWK(ctx, key), nil
327325
}
328326

329327
func ContextWithJWK(ctx context.Context, key jwk.Key) context.Context {
@@ -339,10 +337,10 @@ func GetJWKFromContext(ctx context.Context) jwk.Key {
339337
return jwk
340338
}
341339

342-
return nil
340+
panic("got something that is not a jwk.Key from the JWK context")
343341
}
344342

345-
func validateDPoP(accessToken jwt.Token, acessTokenRaw string, dpopInfo dpopInfo) (*jwk.Key, error) {
343+
func validateDPoP(accessToken jwt.Token, acessTokenRaw string, dpopInfo dpopInfo) (jwk.Key, error) {
346344
if len(dpopInfo.headers) != 1 {
347345
return nil, fmt.Errorf("got %d dpop headers, should have 1", len(dpopInfo.headers))
348346
}
@@ -457,7 +455,7 @@ func validateDPoP(accessToken jwt.Token, acessTokenRaw string, dpopInfo dpopInfo
457455
if ath != base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil)) {
458456
return nil, fmt.Errorf("incorrect `ath` claim in DPoP JWT")
459457
}
460-
return &dpopKey, nil
458+
return dpopKey, nil
461459
}
462460

463461
func (a Authentication) isPublicRoute(path string) func(string) bool {

service/internal/auth/authn_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ func (s *AuthSuite) SetupTest() {
144144
context.Background(),
145145
Config{
146146
AuthNConfig: AuthNConfig{
147-
Issuer: s.server.URL,
148-
Audience: "test",
147+
EnforceDPoP: false,
148+
Issuer: s.server.URL,
149+
Audience: "test",
149150
},
150151
PublicRoutes: []string{"/public", "/public2/*", "/public3/static", "/static/*", "/static/*/*"},
151152
},
@@ -545,6 +546,33 @@ func makeDPoPToken(t *testing.T, tc dpopTestCase) string {
545546
return string(signedToken)
546547
}
547548

549+
func (s *AuthSuite) Test_Allowing_Auth_With_No_DPoP() {
550+
authnConfig := AuthNConfig{
551+
EnforceDPoP: true,
552+
Issuer: s.server.URL,
553+
Audience: "test",
554+
}
555+
config := Config{}
556+
config.AuthNConfig = authnConfig
557+
auth, err := NewAuthenticator(context.Background(), config, nil)
558+
559+
s.Require().NoError(err)
560+
561+
tok := jwt.New()
562+
s.Require().NoError(tok.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)))
563+
s.Require().NoError(tok.Set("iss", s.server.URL))
564+
s.Require().NoError(tok.Set("aud", "test"))
565+
s.Require().NoError(tok.Set("client_id", "client1"))
566+
signedTok, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, s.key))
567+
568+
s.NotNil(signedTok)
569+
s.Require().NoError(err)
570+
571+
_, ctx, err := auth.checkToken(context.Background(), []string{fmt.Sprintf("Bearer %s", string(signedTok))}, dpopInfo{})
572+
s.Require().NoError(err)
573+
s.Require().Nil(GetJWKFromContext(ctx))
574+
}
575+
548576
func (s *AuthSuite) Test_PublicPath_Matches() {
549577
// Passing routes
550578
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public")))

service/internal/auth/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Config struct {
1111

1212
// AuthNConfig is the configuration need for the platform to validate tokens
1313
type AuthNConfig struct {
14+
EnforceDPoP bool `yaml:"enforceDPoP" json:"enforceDPoP" default:"true"`
1415
Issuer string `yaml:"issuer" json:"issuer"`
1516
Audience string `yaml:"audience" json:"audience"`
1617
OIDCConfiguration `yaml:"-" json:"-"`

service/internal/server/server.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ func NewOpenTDFServer(config Config, d *db.Client) (*OpenTDFServer, error) {
9090
// Add authN interceptor
9191
// TODO Remove this conditional once we move to the hardening phase (https://github.com/opentdf/platform/issues/381)
9292
if config.Auth.Enabled {
93-
slog.Info("authentication enabled")
9493
authN, err = auth.NewAuthenticator(
9594
context.Background(),
9695
config.Auth,
@@ -99,6 +98,9 @@ func NewOpenTDFServer(config Config, d *db.Client) (*OpenTDFServer, error) {
9998
if err != nil {
10099
return nil, fmt.Errorf("failed to create authentication interceptor: %w", err)
101100
}
101+
slog.Error("disabling authentication. this is deprecated and will be removed. if you are using an IdP without DPoP set `enforceDPoP = false`")
102+
} else {
103+
slog.Info("authentication enabled")
102104
}
103105

104106
// Try an register oidc issuer to wellknown service but don't return an error if it fails
@@ -162,6 +164,8 @@ func newHttpServer(c Config, h http.Handler, a *auth.Authentication, g *grpc.Ser
162164
// TODO check if this is needed or if it is handled by gRPC
163165
if c.Auth.Enabled {
164166
h = a.MuxHandler(h)
167+
} else {
168+
slog.Error("disabling authentication. this is deprecated and will be removed. if you are using an IdP without DPoP set `enforceDPoP = false`")
165169
}
166170

167171
// Add CORS // TODO We need to make cors configurable (https://github.com/opentdf/platform/issues/305)
@@ -218,9 +222,10 @@ func newGrpcServer(c Config, a *auth.Authentication) (*grpc.Server, error) {
218222
slog.Warn("failed to create proto validator", slog.String("error", err.Error()))
219223
}
220224

221-
// Add authN interceptor
222225
if c.Auth.Enabled {
223226
i = append(i, a.UnaryServerInterceptor)
227+
} else {
228+
slog.Error("disabling authentication. this is deprecated and will be removed. if you are using an IdP without DPoP you can set `enforceDpop = false`")
224229
}
225230

226231
// Add tls creds if tls is not nil

service/kas/access/rewrap.go

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,23 +87,31 @@ func verifySignedRequestToken(ctx context.Context, in *kaspb.RewrapRequest) (*Re
8787
// get dpop public key from context
8888
dpopJWK := auth.GetJWKFromContext(ctx)
8989

90-
// if we don't have a dpop public key then we can't verify the request
90+
var token jwt.Token
91+
var err error
9192
if dpopJWK == nil {
92-
slog.ErrorContext(ctx, "missing dpop public key")
93-
return nil, err401("dpop public key missing")
94-
}
95-
96-
// verify and validate the request token
97-
token, err := jwt.Parse([]byte(in.GetSignedRequestToken()),
98-
jwt.WithKey(dpopJWK.Algorithm(), dpopJWK),
99-
jwt.WithValidate(true),
100-
)
101-
// we have failed to verify the signed request token
102-
if err != nil {
103-
slog.WarnContext(ctx, "unable to verify request token", "err", err)
104-
return nil, err401("unable to verify request token")
93+
slog.InfoContext(ctx, "no DPoP key provided")
94+
// if we have no DPoP key it's for one of two reasons:
95+
// 1. auth is disabled so we can't get a DPoP JWK
96+
// 2. auth is enabled _but_ we aren't requiring DPoP
97+
// in either case letting the request through makes sense
98+
token, err = jwt.Parse([]byte(in.GetSignedRequestToken()), jwt.WithValidate(false))
99+
if err != nil {
100+
slog.WarnContext(ctx, "unable to verify parse token", "err", err)
101+
return nil, err401("could not parse token")
102+
}
103+
} else {
104+
// verify and validate the request token
105+
token, err = jwt.Parse([]byte(in.GetSignedRequestToken()),
106+
jwt.WithKey(dpopJWK.Algorithm(), dpopJWK),
107+
jwt.WithValidate(true),
108+
)
109+
// we have failed to verify the signed request token
110+
if err != nil {
111+
slog.WarnContext(ctx, "unable to verify request token", "err", err)
112+
return nil, err401("unable to verify request token")
113+
}
105114
}
106-
107115
rb, exists := token.Get("requestBody")
108116
if !exists {
109117
slog.WarnContext(ctx, "missing request body")

service/pkg/server/start_test.go

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"context"
55
"fmt"
66
"io"
7-
"net"
87
"net/http"
8+
"net/http/httptest"
99
"os"
1010
"testing"
1111
"time"
@@ -29,7 +29,7 @@ type TestServiceService interface{}
2929
type TestService struct{}
3030

3131
func (t TestService) TestHandler(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
32-
_, err := w.Write([]byte("hello " + pathParams["name"] + " from test service!"))
32+
_, err := w.Write([]byte("hello from test service!"))
3333
if err != nil {
3434
panic(err)
3535
}
@@ -48,37 +48,52 @@ func ServiceRegistrationTest() serviceregistry.Registration {
4848
if !ok {
4949
return fmt.Errorf("Surprise! Not a TestService")
5050
}
51-
return mux.HandlePath(http.MethodGet, "/testpath/{name}", t.TestHandler)
51+
return mux.HandlePath(http.MethodGet, "/healthz", t.TestHandler)
5252
}
5353
},
5454
}
5555
}
5656

5757
func Test_Start_When_Extra_Service_Registered_Expect_Response(t *testing.T) {
58-
// Start wiremock
59-
wiremock, err := startWireMock()
60-
require.NoError(t, err)
61-
62-
defer func() {
63-
err := wiremock.Terminate(context.Background())
64-
require.NoError(t, err)
65-
}()
58+
discoveryURL := "not set yet"
59+
60+
discoveryEndpoint := httptest.NewServer(
61+
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
62+
var resp string
63+
switch req.URL.Path {
64+
case "/.well-known/openid-configuration":
65+
resp = `{
66+
"issuer": "https://example.com",
67+
"authorization_endpoint": "https://example.com/oauth2/v1/authorize",
68+
"token_endpoint": "https://example.com/oauth2/v1/token",
69+
"userinfo_endpoint": "https://example.com/oauth2/v1/userinfo",
70+
"registration_endpoint": "https://example.com/oauth2/v1/clients",
71+
"jwks_uri": "` + discoveryURL + `/oauth2/v1/keys"
72+
}`
73+
case "/oauth2/v1/keys":
74+
resp = `{
75+
"keys":[{"kty":"RSA","alg":"RS256","kid":"saqvCEEc1QX1kjGRh3sf0o4bdPMiiQBVj9xYz95M-X0","use":"sig","e":"AQAB","n":"yXgJvKqNfKoOoc1KiTg8QYfAO2AA47PjHtqZFsPSh93FI3tobD52t1I9cbD7ZotIYfYmZ6KwDvtrAIMVAPKvqvVUji3xSsNQ_Vv4XRmoWwP1vgJNJxoHOyj7pfDdhjplZZaQEcEEpm_J9rXN6V2lLyL6zYLJr_SlI5JeMc8i0tigFW_yLTUpSQ_85r5fAvkr0VDeUHfonaueaFhF5r-fne-F9EZzAVZvG3P8IG8_K6NEoM6muzsplPWJ-95hheRa3Zh58vYTVHcX8DXd8rpS3laUlLuEmIVs-FlqYrIBKpP2spQYGRvf-P1wpNftMH7OTB4j6ULQjwlNRmiQ34TOhw"}]
76+
}`
77+
default:
78+
w.WriteHeader(http.StatusNotFound)
79+
return
80+
}
81+
_, _ = w.Write([]byte(resp))
82+
}),
83+
)
6684

67-
port, err := wiremock.MappedPort(context.Background(), "8184/tcp")
68-
require.NoError(t, err)
85+
discoveryURL = discoveryEndpoint.URL
6986

70-
host := net.JoinHostPort("localhost", port.Port())
7187
// Create new opentdf server
7288
d, _ := db.NewClient(db.Config{})
7389
s, err := server.NewOpenTDFServer(server.Config{
7490
WellKnownConfigRegister: func(namespace string, config any) error {
7591
return nil
7692
},
7793
Auth: auth.Config{
78-
Enabled: true,
7994
AuthNConfig: auth.AuthNConfig{
80-
Issuer: fmt.Sprintf("http://%s/auth", host),
81-
Audience: "opentdf",
95+
Issuer: discoveryEndpoint.URL,
96+
Audience: "test",
8297
},
8398
PublicRoutes: []string{"/testpath/*"},
8499
},
@@ -107,7 +122,7 @@ func Test_Start_When_Extra_Service_Registered_Expect_Response(t *testing.T) {
107122
var resp *http.Response
108123
// Make request to test service and ensure it registered
109124
for i := 3; i > 0; i-- {
110-
resp, err = http.Get("http://localhost:43481/testpath/world")
125+
resp, err = http.Get("http://localhost:43481/healthz")
111126
if err == nil {
112127
break
113128
}
@@ -122,7 +137,7 @@ func Test_Start_When_Extra_Service_Registered_Expect_Response(t *testing.T) {
122137
respBody, err := io.ReadAll(resp.Body)
123138

124139
require.NoError(t, err)
125-
assert.Equal(t, "hello world from test service!", string(respBody))
140+
assert.Equal(t, "hello from test service!", string(respBody))
126141
}
127142

128143
func startWireMock() (tc.Container, error) {

0 commit comments

Comments
 (0)