fix(openapi): document authentik public paths as public
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
0dc9695b91
commit
eb771875e2
2 changed files with 166 additions and 9 deletions
79
openapi.go
79
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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue