From eb771875e2687a213b21522e449da2c99b610eb6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:03:16 +0000 Subject: [PATCH] fix(openapi): document authentik public paths as public Co-Authored-By: Virgil --- openapi.go | 79 +++++++++++++++++++++++++++++++++++----- openapi_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/openapi.go b/openapi.go index c313354..aa55940 100644 --- a/openapi.go +++ b/openapi.go @@ -288,29 +288,53 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { graphqlPath := sb.effectiveGraphQLPath() if graphqlPath != "" { graphqlPath = normaliseOpenAPIPath(graphqlPath) - paths[graphqlPath] = graphqlPathItem(graphqlPath, operationIDs) + item := graphqlPathItem(graphqlPath, operationIDs) + if sb.isPublicOperationPath(graphqlPath) { + makePathItemPublic(item) + } + paths[graphqlPath] = item if sb.GraphQLPlayground { playgroundPath := normaliseOpenAPIPath(graphqlPath + "/playground") - paths[playgroundPath] = graphqlPlaygroundPathItem(playgroundPath, operationIDs) + item := graphqlPlaygroundPathItem(playgroundPath, operationIDs) + if sb.isPublicOperationPath(playgroundPath) { + makePathItemPublic(item) + } + paths[playgroundPath] = item } } if wsPath := sb.effectiveWSPath(); wsPath != "" { wsPath = normaliseOpenAPIPath(wsPath) - paths[wsPath] = wsPathItem(wsPath, operationIDs) + item := wsPathItem(wsPath, operationIDs) + if sb.isPublicOperationPath(wsPath) { + makePathItemPublic(item) + } + paths[wsPath] = item } if ssePath := sb.effectiveSSEPath(); ssePath != "" { ssePath = normaliseOpenAPIPath(ssePath) - paths[ssePath] = ssePathItem(ssePath, operationIDs) + item := ssePathItem(ssePath, operationIDs) + if sb.isPublicOperationPath(ssePath) { + makePathItemPublic(item) + } + paths[ssePath] = item } if sb.PprofEnabled { - paths["/debug/pprof"] = pprofPathItem(operationIDs) + item := pprofPathItem(operationIDs) + if sb.isPublicOperationPath("/debug/pprof") { + makePathItemPublic(item) + } + paths["/debug/pprof"] = item } if sb.ExpvarEnabled { - paths["/debug/vars"] = expvarPathItem(operationIDs) + item := expvarPathItem(operationIDs) + if sb.isPublicOperationPath("/debug/vars") { + makePathItemPublic(item) + } + paths["/debug/vars"] = item } for _, g := range groups { @@ -319,18 +343,25 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any { method := strings.ToLower(rd.Method) deprecated := rd.Deprecated || strings.TrimSpace(rd.SunsetDate) != "" || strings.TrimSpace(rd.Replacement) != "" deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement) + isPublic := sb.isPublicOperationPath(fullPath) + security := rd.Security + if isPublic { + security = []map[string][]string{} + } operation := map[string]any{ "summary": rd.Summary, "description": rd.Description, "operationId": operationID(method, fullPath, operationIDs), - "responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, rd.Security, deprecated, rd.SunsetDate, rd.Replacement, deprecationHeaders), + "responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, security, deprecated, rd.SunsetDate, rd.Replacement, deprecationHeaders), } if deprecated { operation["deprecated"] = true } - if rd.Security != nil { - operation["security"] = rd.Security + if isPublic { + operation["security"] = []any{} + } else if security != nil { + operation["security"] = security } else { operation["security"] = []any{ map[string]any{ @@ -1917,6 +1948,17 @@ func (sb *SpecBuilder) effectiveAuthentikPublicPaths() []string { return normalisePublicPaths(paths) } +// isPublicOperationPath reports whether an OpenAPI path should be documented +// as public because Authentik bypasses it in the running engine. +func (sb *SpecBuilder) isPublicOperationPath(path string) bool { + for _, publicPath := range sb.effectiveAuthentikPublicPaths() { + if isPublicPath(path, publicPath) { + return true + } + } + return false +} + // hasAuthentikMetadata reports whether the spec carries any Authentik-related // configuration worth surfacing. func (sb *SpecBuilder) hasAuthentikMetadata() bool { @@ -1930,6 +1972,25 @@ func (sb *SpecBuilder) hasAuthentikMetadata() bool { len(sb.AuthentikPublicPaths) > 0 } +// makePathItemPublic strips auth-specific responses and marks every operation +// within the path item as public. +func makePathItemPublic(pathItem map[string]any) { + for _, rawOperation := range pathItem { + operation, ok := rawOperation.(map[string]any) + if !ok { + continue + } + + operation["security"] = []any{} + responses, ok := operation["responses"].(map[string]any) + if !ok { + continue + } + delete(responses, "401") + delete(responses, "403") + } +} + // documentedResponseHeaders converts route-specific response header metadata // into OpenAPI header objects. func documentedResponseHeaders(headers map[string]string) map[string]any { diff --git a/openapi_test.go b/openapi_test.go index 9c78c44..d445407 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -1465,6 +1465,102 @@ func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) { } } +func TestSpecBuilder_Good_AuthentikPublicPathsMakeMatchingOperationsPublic(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + AuthentikPublicPaths: []string{"/api/public"}, + } + + group := &specStubGroup{ + name: "security", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/public", + Summary: "Public endpoint", + Security: []map[string][]string{{"bearerAuth": []string{}}}, + Response: map[string]any{ + "type": "object", + }, + }, + }, + } + + data, err := sb.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + op := spec["paths"].(map[string]any)["/api/public"].(map[string]any)["get"].(map[string]any) + security, ok := op["security"].([]any) + if !ok { + t.Fatalf("expected public route security array, got %T", op["security"]) + } + if len(security) != 0 { + t.Fatalf("expected public route to be documented without auth, got %v", security) + } + + responses := op["responses"].(map[string]any) + if _, ok := responses["401"]; ok { + t.Fatal("expected public route to omit 401 response documentation") + } + if _, ok := responses["403"]; ok { + t.Fatal("expected public route to omit 403 response documentation") + } + + paths := spec["x-authentik-public-paths"].([]any) + if len(paths) == 0 || paths[0] != "/health" { + t.Fatalf("expected public path extension to include /health first, got %v", paths) + } +} + +func TestSpecBuilder_Good_AuthentikPublicPathsMakeBuiltInEndpointsPublic(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + GraphQLEnabled: true, + GraphQLPath: "/graphql", + AuthentikPublicPaths: []string{"/graphql"}, + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + pathItem := spec["paths"].(map[string]any)["/graphql"].(map[string]any) + for _, method := range []string{"get", "post"} { + op := pathItem[method].(map[string]any) + security, ok := op["security"].([]any) + if !ok { + t.Fatalf("expected %s security array, got %T", method, op["security"]) + } + if len(security) != 0 { + t.Fatalf("expected %s operation to be documented without auth, got %v", method, security) + } + + responses := op["responses"].(map[string]any) + if _, ok := responses["401"]; ok { + t.Fatalf("expected %s operation to omit 401 response documentation", method) + } + if _, ok := responses["403"]; ok { + t.Fatalf("expected %s operation to omit 403 response documentation", method) + } + } +} + func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",