Skip to content

Commit 7c65308

Browse files
authored
feat: ability to add public routes that bypass authn middleware (#601)
Adds the ability to allow extra public routes when extended the platform or if a route requires its own auth mechanism. - adds new wiremocks for jwks keys - new `WithPublicRoutes` options func for extending platform
1 parent 5069f9d commit 7c65308

File tree

12 files changed

+246
-81
lines changed

12 files changed

+246
-81
lines changed

service/integration/wiremock/mappings/oidc_jks.json renamed to service/integration/wiremock/mappings/oidc_config.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
"method": "GET",
44
"urlPath": "/auth/.well-known/openid-configuration"
55
},
6-
76
"response": {
87
"status": 200,
9-
"bodyFileName": "messages/oidc_jwks.json",
10-
"transformers": ["response-template"]
8+
"bodyFileName": "messages/oidc_config.json",
9+
"transformers": [
10+
"response-template"
11+
]
1112
}
1213
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"request": {
3+
"method": "GET",
4+
"urlPath": "/auth/keys"
5+
},
6+
"response": {
7+
"status": 200,
8+
"bodyFileName": "messages/oidc_jwks.json",
9+
"transformers": [
10+
"response-template"
11+
]
12+
}
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"issuer": "{{request.baseUrl}}/mock-server",
3+
"authorization_endpoint": "{{request.baseUrl}}/auth/authorize",
4+
"token_endpoint": "{{request.baseUrl}}/auth/token",
5+
"userinfo_endpoint": "{{request.baseUrl}}/auth/userinfo",
6+
"registration_endpoint": "{{request.baseUrl}}/auth/clients",
7+
"jwks_uri": "{{request.baseUrl}}/auth/keys",
8+
"response_types_supported": ["code", "id_token", "code id_token", "code token", "id_token token", "code id_token token"],
9+
"response_modes_supported": ["query", "fragment", "form_post", "okta_post_message"],
10+
"grant_types_supported": ["authorization_code", "implicit", "refresh_token", "password"],
11+
"subject_types_supported": ["public"],
12+
"id_token_signing_alg_values_supported": ["RS256"],
13+
"scopes_supported": ["sms", "openid", "profile", "email", "address", "phone", "offline_access"],
14+
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
15+
"claims_supported": ["iss", "ver", "sub", "aud", "iat", "exp", "jti", "auth_time", "amr", "idp", "nonce", "name", "nickname", "preferred_username", "given_name", "middle_name", "family_name", "email", "email_verified", "profile", "zoneinfo", "locale", "address", "phone_number", "picture", "website", "gender", "birthdate", "updated_at", "at_hash", "c_hash"],
16+
"code_challenge_methods_supported": ["S256"],
17+
"introspection_endpoint": "{{request.baseUrl}}/auth/introspect",
18+
"introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
19+
"revocation_endpoint": "{{request.baseUrl}}/auth/revoke",
20+
"revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
21+
"end_session_endpoint": "{{request.baseUrl}}/auth/logout",
22+
"request_parameter_supported": true,
23+
"request_object_signing_alg_values_supported": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
24+
}
Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
{
2-
"issuer": "{{request.baseUrl}}/mock-server",
3-
"authorization_endpoint": "{{request.baseUrl}}/auth/authorize",
4-
"token_endpoint": "{{request.baseUrl}}/auth/token",
5-
"userinfo_endpoint": "{{request.baseUrl}}/auth/userinfo",
6-
"registration_endpoint": "{{request.baseUrl}}/auth/clients",
7-
"jwks_uri": "{{request.baseUrl}}/auth/keys",
8-
"response_types_supported": ["code", "id_token", "code id_token", "code token", "id_token token", "code id_token token"],
9-
"response_modes_supported": ["query", "fragment", "form_post", "okta_post_message"],
10-
"grant_types_supported": ["authorization_code", "implicit", "refresh_token", "password"],
11-
"subject_types_supported": ["public"],
12-
"id_token_signing_alg_values_supported": ["RS256"],
13-
"scopes_supported": ["sms", "openid", "profile", "email", "address", "phone", "offline_access"],
14-
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
15-
"claims_supported": ["iss", "ver", "sub", "aud", "iat", "exp", "jti", "auth_time", "amr", "idp", "nonce", "name", "nickname", "preferred_username", "given_name", "middle_name", "family_name", "email", "email_verified", "profile", "zoneinfo", "locale", "address", "phone_number", "picture", "website", "gender", "birthdate", "updated_at", "at_hash", "c_hash"],
16-
"code_challenge_methods_supported": ["S256"],
17-
"introspection_endpoint": "{{request.baseUrl}}/auth/introspect",
18-
"introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
19-
"revocation_endpoint": "{{request.baseUrl}}/auth/revoke",
20-
"revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"],
21-
"end_session_endpoint": "{{request.baseUrl}}/auth/logout",
22-
"request_parameter_supported": true,
23-
"request_object_signing_alg_values_supported": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
24-
}
2+
"keys": [
3+
{
4+
"kid": "STrsCH5CwD6SPwfZrC1gSb8ST91qsWyjleb0QGqSEJA",
5+
"kty": "RSA",
6+
"alg": "RSA-OAEP",
7+
"use": "enc",
8+
"n": "k0e-Xgq-GxG49XJRUP9bzswukbeL1geMlUkD47w7N6a1Rc-cq9kbc3hIZtseGKBRhIN3jKKHdJKNl0iSFu0P5YzjP-13IgYkqhEEodXfIMC6RHgeiJxZqNIonVPvBxWu6iWoqxRY_HgsFtGHhR1OfLP33wXzhZ2Osoc_RKK1D9kz_pbvDHMbaaUvNyCnyqGeqPVw5kpwBb5pi-BPapXvvRTWzbXKxipw59fZ1aSYL-JwjkbpNIdISSZCdmk0lzljKPWq59hVn3lVH2lZz1NZp3eypxFInaQqPwBehPRnwiPVBcZ6yX5zynZ_knUcOHiS65OmDPCoFpWqWJGL1YFHvw",
9+
"e": "AQAB",
10+
"x5c": [
11+
"MIICnTCCAYUCBgGO1+er8TANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdvcGVudGRmMB4XDTI0MDQxMzE0MzkyOFoXDTM0MDQxMzE0NDEwOFowEjEQMA4GA1UEAwwHb3BlbnRkZjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJNHvl4KvhsRuPVyUVD/W87MLpG3i9YHjJVJA+O8OzemtUXPnKvZG3N4SGbbHhigUYSDd4yih3SSjZdIkhbtD+WM4z/tdyIGJKoRBKHV3yDAukR4HoicWajSKJ1T7wcVruolqKsUWPx4LBbRh4UdTnyz998F84WdjrKHP0SitQ/ZM/6W7wxzG2mlLzcgp8qhnqj1cOZKcAW+aYvgT2qV770U1s21ysYqcOfX2dWkmC/icI5G6TSHSEkmQnZpNJc5Yyj1qufYVZ95VR9pWc9TWad3sqcRSJ2kKj8AXoT0Z8Ij1QXGesl+c8p2f5J1HDh4kuuTpgzwqBaVqliRi9WBR78CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAa5gfdx6X3IwndMH/x9xsjqUL1EpmLLuAXVh4NFCLs6roA1Xien4A+2y+trTjfImC9hIyEM+iEy9kQdB1ddMZC9PkzxQy94p2QZeIY5lBicxRLPS05rAeQXoHxP46TD7Mzat7txiNCtJZn3Y1bOdEX/SpGosWZ+vVrQMshAwOhBGcApZxW5rhnMBamzO5bQiX53gNdexc0vhZ+mzNURvvCt/XSbyQmyp8537l96AhHDiBScriKcF+J/o42+IV6b2NuFttzf3/Fj9wi2DIAw1DStChos/1XsCtGgLkVJSFxR+JhZfGS4wcEfqfhWhWppgrWPk9UdpjYr6bcl+BSPXvQw=="
12+
],
13+
"x5t": "K6PhEHOchyu2P--3km64mwIct1A",
14+
"x5t#S256": "xkRwZKknmRIiS3nQKoZk_Cw9KH01Wb-o2Qmd8Niy_FA"
15+
},
16+
{
17+
"kid": "CKB_TehGsxMsfhaFyKRAFHV9PveHT3UN0QmomB7GJgM",
18+
"kty": "RSA",
19+
"alg": "RS256",
20+
"use": "sig",
21+
"n": "xdGdCYsc3gZtGnLZkWccFlznMVrW_tzEeiflBiCHg8lhH4Tgjw8tZA4DMrueXRGJXqTbpSIjMhpapkSkYIOQV8Zg5zxSo-aahaD6FMzxFKIA7m1TGtwTpOik536Njrii_iRBiekw_o1Ua5KShN_4cptw9a8HN8aP826H_pPLQlOFCWMsMHw8taS2vt59OzRfAOf9BG4gIj56Od4XzQxI2ICNGVoZKADKNbWd0FDMEZqEoe5cDo8mGmO4ouYJpvch7InnC6VNzbJMpTt3sAyawmpAKAI9R4eW96HsUK54foLMBNjGlzKQnJIkisp6YipMqc_43EWYEhQuDa3cdpULJQ",
22+
"e": "AQAB",
23+
"x5c": [
24+
"MIICnTCCAYUCBgGO1+erTjANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdvcGVudGRmMB4XDTI0MDQxMzE0MzkyN1oXDTM0MDQxMzE0NDEwN1owEjEQMA4GA1UEAwwHb3BlbnRkZjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMXRnQmLHN4GbRpy2ZFnHBZc5zFa1v7cxHon5QYgh4PJYR+E4I8PLWQOAzK7nl0RiV6k26UiIzIaWqZEpGCDkFfGYOc8UqPmmoWg+hTM8RSiAO5tUxrcE6TopOd+jY64ov4kQYnpMP6NVGuSkoTf+HKbcPWvBzfGj/Nuh/6Ty0JThQljLDB8PLWktr7efTs0XwDn/QRuICI+ejneF80MSNiAjRlaGSgAyjW1ndBQzBGahKHuXA6PJhpjuKLmCab3IeyJ5wulTc2yTKU7d7AMmsJqQCgCPUeHlveh7FCueH6CzATYxpcykJySJIrKemIqTKnP+NxFmBIULg2t3HaVCyUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAk7RMcdoS500NL2seTQPSN0dIb1jSSskN0IPo2rmTbh2VupWuABMv3pKUzzp0LcyDdwJhdoF44g0UAzlhnqkPhBGG9H7QfGblnCSwcGX96tRsAu8f1dxlGoE1DJWay4OqNAZuSgd8/7kxaCVdK4rURhQdxW+nuO7HfeuwKpi7zFp3ubm2WVAZUAirbQ8CIS02Z5ki6q+l0rDe10RXVUUg6v0uLIF/WpsMEoLjWv7EV4E4qkRk0YYXN74o3BJgJknvyMXnUO1wb69sOFaY627jVxlksCpJ4780lOYnoqQmDXCGceggzTMrXCNOJBiSKwjndaUEseym2CNhjtNfESpdIg=="
25+
],
26+
"x5t": "2poAImSunrm90ff8mrVWeCM8Jh8",
27+
"x5t#S256": "aHHEYJnhNPkbS9DgOL6RrtMVEZLuRyefFBFZRBMJHnU"
28+
}
29+
]
30+
}

service/internal/auth/authn.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"log/slog"
1010
"net/http"
11+
"path/filepath"
1112
"slices"
1213
"strings"
1314
"time"
@@ -30,14 +31,11 @@ const (
3031
type authContextKey string
3132

3233
var (
33-
// Set of allowed gRPC endpoints that do not require authentication
34-
allowedGRPCEndpoints = [...]string{
34+
// Set of allowed public endpoints that do not require authentication
35+
allowedPublicEndpoints = [...]string{
3536
"/grpc.health.v1.Health/Check",
3637
"/wellknownconfiguration.WellKnownService/GetWellKnownConfiguration",
3738
"/kas.AccessService/PublicKey",
38-
}
39-
// Set of allowed HTTP endpoints that do not require authentication
40-
allowedHTTPEndpoints = [...]string{
4139
"/healthz",
4240
"/.well-known/opentdf-configuration",
4341
"/kas/v2/kas_public_key",
@@ -66,10 +64,12 @@ type Authentication struct {
6664
oidcConfigurations map[string]AuthNConfig
6765
// Casbin enforcer
6866
enforcer *Enforcer
67+
// Public Routes HTTP & gRPC
68+
publicRoutes []string
6969
}
7070

7171
// Creates new authN which is used to verify tokens for a set of given issuers
72-
func NewAuthenticator(ctx context.Context, cfg AuthNConfig, d *db.Client) (*Authentication, error) {
72+
func NewAuthenticator(ctx context.Context, cfg Config, d *db.Client) (*Authentication, error) {
7373
a := &Authentication{}
7474
a.oidcConfigurations = make(map[string]AuthNConfig)
7575

@@ -117,7 +117,11 @@ func NewAuthenticator(ctx context.Context, cfg AuthNConfig, d *db.Client) (*Auth
117117
return nil, err
118118
}
119119

120-
a.oidcConfigurations[cfg.Issuer] = cfg
120+
// Combine public routes
121+
a.publicRoutes = append(a.publicRoutes, cfg.PublicRoutes...)
122+
a.publicRoutes = append(a.publicRoutes, allowedPublicEndpoints[:]...)
123+
124+
a.oidcConfigurations[cfg.Issuer] = cfg.AuthNConfig
121125

122126
return a, nil
123127
}
@@ -131,7 +135,7 @@ type dpopInfo struct {
131135
// verifyTokenHandler is a http handler that verifies the token
132136
func (a Authentication) MuxHandler(handler http.Handler) http.Handler {
133137
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134-
if slices.Contains(allowedHTTPEndpoints[:], r.URL.Path) {
138+
if slices.ContainsFunc(a.publicRoutes, a.isPublicRoute(r.URL.Path)) {
135139
handler.ServeHTTP(w, r)
136140
return
137141
}
@@ -187,7 +191,7 @@ func (a Authentication) MuxHandler(handler http.Handler) http.Handler {
187191
// UnaryServerInterceptor is a grpc interceptor that verifies the token in the metadata
188192
func (a Authentication) UnaryServerInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
189193
// Allow health checks to pass through
190-
if slices.Contains(allowedGRPCEndpoints[:], info.FullMethod) {
194+
if slices.ContainsFunc(a.publicRoutes, a.isPublicRoute(info.FullMethod)) {
191195
return handler(ctx, req)
192196
}
193197

@@ -455,3 +459,15 @@ func validateDPoP(accessToken jwt.Token, acessTokenRaw string, dpopInfo dpopInfo
455459
}
456460
return &dpopKey, nil
457461
}
462+
463+
func (a Authentication) isPublicRoute(path string) func(string) bool {
464+
return func(route string) bool {
465+
matched, err := filepath.Match(route, path)
466+
if err != nil {
467+
slog.Warn("error matching route", slog.String("route", route), slog.String("path", path), slog.String("error", err.Error()))
468+
return false
469+
}
470+
slog.Debug("matching route", slog.String("route", route), slog.String("path", path), slog.Bool("matched", matched))
471+
return matched
472+
}
473+
}

service/internal/auth/authn_test.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"net"
1515
"net/http"
1616
"net/http/httptest"
17+
"slices"
1718
"testing"
1819
"time"
1920

@@ -141,10 +142,14 @@ func (s *AuthSuite) SetupTest() {
141142

142143
auth, err := NewAuthenticator(
143144
context.Background(),
144-
AuthNConfig{
145-
Issuer: s.server.URL,
146-
Audience: "test",
147-
}, nil)
145+
Config{
146+
AuthNConfig: AuthNConfig{
147+
Issuer: s.server.URL,
148+
Audience: "test",
149+
},
150+
PublicRoutes: []string{"/public", "/public2/*", "/public3/static", "/static/*", "/static/*/*"},
151+
},
152+
nil)
148153

149154
s.Require().NoError(err)
150155

@@ -539,3 +544,19 @@ func makeDPoPToken(t *testing.T, tc dpopTestCase) string {
539544
}
540545
return string(signedToken)
541546
}
547+
548+
func (s *AuthSuite) Test_PublicPath_Matches() {
549+
// Passing routes
550+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public")))
551+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public2/test")))
552+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public3/static")))
553+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public2/")))
554+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/static/test")))
555+
s.Require().True(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/static/test/next")))
556+
557+
// Failing routes
558+
s.Require().False(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public3/")))
559+
s.Require().False(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public2")))
560+
s.Require().False(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/private")))
561+
s.Require().False(slices.ContainsFunc(s.auth.publicRoutes, s.auth.isPublicRoute("/public2/test/fail")))
562+
}

service/internal/auth/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import "fmt"
44

55
// AuthConfig pulls AuthN and AuthZ together
66
type Config struct {
7-
Enabled bool `yaml:"enabled" default:"true" `
8-
AuthNConfig `mapstructure:",squash"`
7+
Enabled bool `yaml:"enabled" default:"true" `
8+
PublicRoutes []string `mapstructure:"-"`
9+
AuthNConfig `mapstructure:",squash"`
910
}
1011

1112
// AuthNConfig is the configuration need for the platform to validate tokens

service/internal/auth/discovery.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"log/slog"
78
"net/http"
89
)
910

@@ -27,6 +28,7 @@ type OIDCConfiguration struct {
2728

2829
// DiscoverOPENIDConfiguration discovers the openid configuration for the issuer provided
2930
func DiscoverOIDCConfiguration(ctx context.Context, issuer string) (*OIDCConfiguration, error) {
31+
slog.DebugContext(ctx, "discovering openid configuration", slog.String("issuer", issuer))
3032
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s%s", issuer, DiscoveryPath), nil)
3133
if err != nil {
3234
return nil, err

service/internal/server/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func NewOpenTDFServer(config Config, d *db.Client) (*OpenTDFServer, error) {
9393
slog.Info("authentication enabled")
9494
authN, err = auth.NewAuthenticator(
9595
context.Background(),
96-
config.Auth.AuthNConfig,
96+
config.Auth,
9797
d,
9898
)
9999
if err != nil {

service/pkg/server/options.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package server
2+
3+
type StartOptions func(StartConfig) StartConfig
4+
5+
type StartConfig struct {
6+
ConfigKey string
7+
ConfigFile string
8+
WaitForShutdownSignal bool
9+
PublicRoutes []string
10+
}
11+
12+
// Deprecated: Use WithConfigKey
13+
func WithConfigName(name string) StartOptions {
14+
return func(c StartConfig) StartConfig {
15+
c.ConfigKey = name
16+
return c
17+
}
18+
}
19+
20+
func WithConfigFile(file string) StartOptions {
21+
return func(c StartConfig) StartConfig {
22+
c.ConfigFile = file
23+
return c
24+
}
25+
}
26+
27+
func WithConfigKey(key string) StartOptions {
28+
return func(c StartConfig) StartConfig {
29+
c.ConfigKey = key
30+
return c
31+
}
32+
}
33+
34+
func WithWaitForShutdownSignal() StartOptions {
35+
return func(c StartConfig) StartConfig {
36+
c.WaitForShutdownSignal = true
37+
return c
38+
}
39+
}
40+
41+
func WithPublicRoutes(routes []string) StartOptions {
42+
return func(c StartConfig) StartConfig {
43+
c.PublicRoutes = routes
44+
return c
45+
}
46+
}

0 commit comments

Comments
 (0)