fix(openapi): document authentik public paths as public

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 14:03:16 +00:00
parent 0dc9695b91
commit eb771875e2
2 changed files with 166 additions and 9 deletions

View file

@ -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 {

View file

@ -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",