From f24939bd9f7a8064a9643441746b5aa190a5ee79 Mon Sep 17 00:00:00 2001 From: Alex Guerrieri Date: Fri, 19 Dec 2025 13:05:09 +0100 Subject: [PATCH 1/4] fix: use GOTOOLCHAIN variable in generate and proto commands --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 91e8d8a..3ae8deb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ TEST_FLAGS ?= -p 8 -failfast -race -shuffle on +GOTOOLCHAIN := $(shell cat go.mod | grep "^go" | tr -d ' ') all: @echo "make :" @@ -22,11 +23,11 @@ test-coverage-inspect: test-coverage go tool cover -html=coverage.out generate: - go generate -x ./... + GOTOOLCHAIN=$(GOTOOLCHAIN) go generate -x ./... .PHONY: proto proto: - go generate -x ./proto/... + GOTOOLCHAIN=$(GOTOOLCHAIN) go generate -x ./proto/... lint: golangci-lint run ./... --fix -c .golangci.yml From 595a706763435895a05c6cf44a1a2f30fea4ce86 Mon Sep 17 00:00:00 2001 From: Alex Guerrieri Date: Fri, 19 Dec 2025 13:05:19 +0100 Subject: [PATCH 2/4] fix: update error messages and versioning in authcontrol errors and generated files --- go.work.sum | 1 + proto/authcontrol.errors.ridl | 23 +++++++++++------------ proto/authcontrol.gen.go | 6 +++--- proto/authcontrol.gen.ts | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.work.sum b/go.work.sum index 4c8099d..01f0bef 100644 --- a/go.work.sum +++ b/go.work.sum @@ -15,6 +15,7 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/proto/authcontrol.errors.ridl b/proto/authcontrol.errors.ridl index f7ba4d4..07f6fd0 100644 --- a/proto/authcontrol.errors.ridl +++ b/proto/authcontrol.errors.ridl @@ -1,16 +1,15 @@ webrpc = v1 name = authcontrol -version = v0.9.1 - -error 1000 Unauthorized "Unauthorized access" HTTP 401 -error 1001 PermissionDenied "Permission denied" HTTP 403 -error 1002 SessionExpired "Session expired" HTTP 403 -error 1003 MethodNotFound "Method not found" HTTP 404 -error 1004 RequestConflict "Conflict with target resource" HTTP 409 -error 1005 Aborted "Request aborted" HTTP 400 -error 1006 Geoblocked "Geoblocked region" HTTP 451 -error 1007 RateLimited "Rate-limited. Please slow down." HTTP 429 -error 1008 ProjectNotFound "Project not found" HTTP 401 -error 1009 SecretKeyCorsDisallowed "CORS disallowed. Admin API Secret Key can't be used from a web app." HTTP 403 +version = v0.4.12 +error 1000 Unauthorized "Unauthorized access" HTTP 401 +error 1001 PermissionDenied "Permission denied" HTTP 403 +error 1002 SessionExpired "Session expired" HTTP 403 +error 1003 MethodNotFound "Method not found" HTTP 404 +error 1004 RequestConflict "Conflict with target resource" HTTP 409 +error 1005 Aborted "Request aborted" HTTP 400 +error 1006 Geoblocked "Geoblocked region" HTTP 451 +error 1007 RateLimited "Rate limit exceeded. Configure an Access Key to increase your limits: https://dashboard.trails.build or https://sequence.build" HTTP 429 +error 1008 ProjectNotFound "Project not found" HTTP 401 +error 1009 SecretKeyCorsDisallowed "CORS disallowed. Admin API Secret Key can't be used from a web app." HTTP 403 diff --git a/proto/authcontrol.gen.go b/proto/authcontrol.gen.go index 6ecbcd5..a58f4bf 100644 --- a/proto/authcontrol.gen.go +++ b/proto/authcontrol.gen.go @@ -1,4 +1,4 @@ -// authcontrol v0.9.1 efc70751a8d3d04b62886c568ebe71265a4e3d5b +// authcontrol v0.9.1 6855ab0cf75fb9058df94efbbb43b02641cd0918 // -- // Code generated by webrpc-gen@v0.22.1 with golang generator. DO NOT EDIT. // @@ -29,7 +29,7 @@ func WebRPCSchemaVersion() string { // Schema hash generated from your RIDL schema func WebRPCSchemaHash() string { - return "efc70751a8d3d04b62886c568ebe71265a4e3d5b" + return "6855ab0cf75fb9058df94efbbb43b02641cd0918" } type WebrpcGenVersions struct { @@ -308,7 +308,7 @@ var ( ErrRequestConflict = WebRPCError{Code: 1004, Name: "RequestConflict", Message: "Conflict with target resource", HTTPStatus: 409} ErrAborted = WebRPCError{Code: 1005, Name: "Aborted", Message: "Request aborted", HTTPStatus: 400} ErrGeoblocked = WebRPCError{Code: 1006, Name: "Geoblocked", Message: "Geoblocked region", HTTPStatus: 451} - ErrRateLimited = WebRPCError{Code: 1007, Name: "RateLimited", Message: "Rate-limited. Please slow down.", HTTPStatus: 429} + ErrRateLimited = WebRPCError{Code: 1007, Name: "RateLimited", Message: "Rate limit exceeded. Configure an Access Key to increase your limits: https://dashboard.trails.build or https://sequence.build", HTTPStatus: 429} ErrProjectNotFound = WebRPCError{Code: 1008, Name: "ProjectNotFound", Message: "Project not found", HTTPStatus: 401} ErrSecretKeyCorsDisallowed = WebRPCError{Code: 1009, Name: "SecretKeyCorsDisallowed", Message: "CORS disallowed. Admin API Secret Key can't be used from a web app.", HTTPStatus: 403} ) diff --git a/proto/authcontrol.gen.ts b/proto/authcontrol.gen.ts index 8d26e76..0253028 100644 --- a/proto/authcontrol.gen.ts +++ b/proto/authcontrol.gen.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// authcontrol v0.9.1 efc70751a8d3d04b62886c568ebe71265a4e3d5b +// authcontrol v0.9.1 6855ab0cf75fb9058df94efbbb43b02641cd0918 // -- // Code generated by webrpc-gen@v0.22.1 with typescript generator. DO NOT EDIT. // @@ -16,7 +16,7 @@ export const WebRPCVersion = "v1" export const WebRPCSchemaVersion = "v0.9.1" // Schema hash generated from your RIDL schema -export const WebRPCSchemaHash = "efc70751a8d3d04b62886c568ebe71265a4e3d5b" +export const WebRPCSchemaHash = "6855ab0cf75fb9058df94efbbb43b02641cd0918" type WebrpcGenVersions = { webrpcGenVersion: string; @@ -391,7 +391,7 @@ export class RateLimitedError extends WebrpcError { constructor( name: string = 'RateLimited', code: number = 1007, - message: string = `Rate-limited. Please slow down.`, + message: string = `Rate limit exceeded. Configure an Access Key to increase your limits: https://dashboard.trails.build or https://sequence.build`, status: number = 0, cause?: string ) { From 63dea3a551ece121a83620df0ec7b75921227312 Mon Sep 17 00:00:00 2001 From: Alex Guerrieri Date: Fri, 19 Dec 2025 13:36:30 +0100 Subject: [PATCH 3/4] add tests for JWT scope --- common_test.go | 6 ++--- middleware.go | 9 +++---- middleware_test.go | 66 +++++++++++++++++++++++++++++++++------------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/common_test.go b/common_test.go index 17a1e19..84f31b2 100644 --- a/common_test.go +++ b/common_test.go @@ -41,7 +41,7 @@ func origin(v string) requestOption { } } -func executeRequest(t *testing.T, ctx context.Context, handler http.Handler, path string, options ...requestOption) (bool, error) { +func executeRequest(t *testing.T, ctx context.Context, handler http.Handler, path string, options ...requestOption) (bool, http.Header, error) { req, err := http.NewRequest("POST", path, nil) require.NoError(t, err) @@ -57,10 +57,10 @@ func executeRequest(t *testing.T, ctx context.Context, handler http.Handler, pat webrpcErr := proto.WebRPCError{} err = json.Unmarshal(rr.Body.Bytes(), &webrpcErr) require.NoError(t, err, "failed to unmarshal response body: %s", rr.Body.Bytes()) - return false, webrpcErr + return false, rr.Header(), webrpcErr } - return true, nil + return true, rr.Header(), nil } func TestVerify(t *testing.T) { diff --git a/middleware.go b/middleware.go index 903f067..3300d47 100644 --- a/middleware.go +++ b/middleware.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "slices" "strconv" "strings" "time" @@ -211,11 +212,9 @@ func Session(cfg Options) func(next http.Handler) http.Handler { } if adminClaim { - if scopeClaim == "" || scopeClaim == cfg.ServiceName || strings.Contains(scopeClaim, cfg.ServiceName) { - // Allow admin if no scope claim is provided or if it matches service name. - sessionType = proto.SessionType_Admin - } else { - // Reduce to public if scope claim does not match. + sessionType = proto.SessionType_Admin + // Reduce to public if a scope is provided and the claim does not match. + if scopeClaim != "" && !slices.Contains(strings.Split(scopeClaim, ","), cfg.ServiceName) { sessionType = proto.SessionType_Public } } diff --git a/middleware_test.go b/middleware_test.go index e463d87..eebcdd1 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -151,7 +151,7 @@ func TestSession(t *testing.T) { session = proto.SessionType_AccessKey } - ok, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", service, method), options...) + ok, _, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", service, method), options...) if !expectedACL.Includes(session) { assert.Error(t, err) assert.False(t, ok) @@ -196,6 +196,7 @@ func TestInvalid(t *testing.T) { AdminAddress: true, }, AccessKeyFuncs: []authcontrol.AccessKeyFunc{keyFunc}, + ServiceName: ServiceName, } r := chi.NewRouter() @@ -216,39 +217,39 @@ func TestInvalid(t *testing.T) { })) // Without JWT - ok, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt("")) + ok, _, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt("")) assert.True(t, ok) assert.NoError(t, err) // Wrong JWT - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt("wrong-secret")) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt("wrong-secret")) assert.False(t, ok) assert.ErrorIs(t, err, proto.ErrUnauthorized) claims := map[string]any{"service": "client_service"} - // Valid Request - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + // Valid S2S Request + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) // Invalid request path with wrong not enough parts in path for valid RPC request, this will delegate to next handler and return no error - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) // Invalid request path with wrong "rpc", this will delegate to next handler and return no error - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/pcr/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/pcr/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) // Invalid Service, this will delegate to next handler and return no error - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceNameInvalid, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceNameInvalid, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) // Invalid Method, this will delegate to next handler and return no error - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodNameInvalid), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodNameInvalid), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) @@ -257,19 +258,46 @@ func TestInvalid(t *testing.T) { expiredJWT := authcontrol.S2SToken(JWTSecret, claims) // Expired JWT Token valid method - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(expiredJWT)) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(expiredJWT)) assert.False(t, ok) assert.ErrorIs(t, err, proto.ErrSessionExpired) // Expired JWT Token invalid service - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceNameInvalid, MethodName), accessKey(AccessKey), jwt(expiredJWT)) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceNameInvalid, MethodName), accessKey(AccessKey), jwt(expiredJWT)) assert.False(t, ok) assert.ErrorIs(t, err, proto.ErrSessionExpired) // Expired JWT Token invalid method - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodNameInvalid), accessKey(AccessKey), jwt(expiredJWT)) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodNameInvalid), accessKey(AccessKey), jwt(expiredJWT)) assert.False(t, ok) assert.ErrorIs(t, err, proto.ErrSessionExpired) + + // Valid Admin Request (no scope claim) + claims = map[string]any{"account": AdminAddress, "admin": true} + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + assert.True(t, ok) + assert.NoError(t, err) + + // Valid Admin Request (with matching scope claim) + claims = map[string]any{"account": AdminAddress, "admin": true, "scope": ServiceName} + ok, headers, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + assert.True(t, ok) + assert.NoError(t, err) + assert.Equal(t, "Admin", headers.Get(authcontrol.HeaderSessionType)) + + // Valid Admin Request (with multiple scope claims) + claims = map[string]any{"account": AdminAddress, "admin": true, "scope": ServiceName + ",other_service"} + ok, headers, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + assert.True(t, ok) + assert.NoError(t, err) + assert.Equal(t, "Admin", headers.Get(authcontrol.HeaderSessionType)) + + // Invalid Admin Request (with non-matching scope claim) + claims = map[string]any{"account": AdminAddress, "admin": true, "scope": "other_service"} + ok, headers, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + assert.True(t, ok) + assert.NoError(t, err) + assert.NotEqual(t, "User", headers.Get(authcontrol.HeaderSessionType)) } func TestCustomErrHandler(t *testing.T) { @@ -328,12 +356,12 @@ func TestCustomErrHandler(t *testing.T) { claims := map[string]any{"service": "client_service"} // Valid Request - ok, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) + ok, _, err := executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName), accessKey(AccessKey), jwt(authcontrol.S2SToken(JWTSecret, claims))) assert.True(t, ok) assert.NoError(t, err) // Invalid Access, should return custom error - ok, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName)) + ok, _, err = executeRequest(t, ctx, r, fmt.Sprintf("/rpc/%s/%s", ServiceName, MethodName)) assert.False(t, ok) assert.ErrorIs(t, err, customErr) } @@ -356,17 +384,17 @@ func TestOrigin(t *testing.T) { }) // No Origin header - ok, err := executeRequest(t, ctx, r, "", jwt(token)) + ok, _, err := executeRequest(t, ctx, r, "", jwt(token)) assert.True(t, ok) assert.NoError(t, err) // Valid Origin header - ok, err = executeRequest(t, ctx, r, "", jwt(token), origin("http://localhost")) + ok, _, err = executeRequest(t, ctx, r, "", jwt(token), origin("http://localhost")) assert.True(t, ok) assert.NoError(t, err) // Invalid Origin header - ok, err = executeRequest(t, ctx, r, "", jwt(token), origin("http://evil.com")) + ok, _, err = executeRequest(t, ctx, r, "", jwt(token), origin("http://evil.com")) assert.False(t, ok) assert.ErrorIs(t, err, proto.ErrUnauthorized) } @@ -403,7 +431,7 @@ func TestProjectVerifier(t *testing.T) { "project_id": projectID, }) - ok, err := executeRequest(t, ctx, r, "", jwt(token)) + ok, _, err := executeRequest(t, ctx, r, "", jwt(token)) assert.True(t, ok) assert.NoError(t, err) @@ -429,7 +457,7 @@ func TestProjectVerifier(t *testing.T) { }) require.NoError(t, err) - ok, err = executeRequest(t, ctx, r, "", jwt(token)) + ok, _, err = executeRequest(t, ctx, r, "", jwt(token)) assert.True(t, ok) assert.NoError(t, err) } From 729d1cd53f4f085f58c5a51a6c78ec0686cf1ebb Mon Sep 17 00:00:00 2001 From: Alex Guerrieri Date: Fri, 19 Dec 2025 14:08:22 +0100 Subject: [PATCH 4/4] inject webrpc schema version --- Makefile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 3ae8deb..047d7b8 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,10 @@ test-coverage-inspect: test-coverage go tool cover -html=coverage.out generate: + WEBRPC_SCHEMA_VERSION=$(shell git log -1 --date=format:'v0-%y.%-m.%-d' --format='%ad+%h' ./proto/*.ridl) \ GOTOOLCHAIN=$(GOTOOLCHAIN) go generate -x ./... -.PHONY: proto -proto: - GOTOOLCHAIN=$(GOTOOLCHAIN) go generate -x ./proto/... +proto: generate lint: golangci-lint run ./... --fix -c .golangci.yml