diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 83589b9..cfffe7b 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -121,6 +121,10 @@ func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { cacheMaxBytes: cfg.cacheMaxBytes, i18nDefaultLocale: cfg.i18nDefaultLocale, i18nSupportedLocales: cfg.i18nSupportedLocales, + authentikIssuer: cfg.authentikIssuer, + authentikClientID: cfg.authentikClientID, + authentikTrustedProxy: cfg.authentikTrustedProxy, + authentikPublicPaths: cfg.authentikPublicPaths, termsURL: cfg.termsURL, contactName: cfg.contactName, contactURL: cfg.contactURL, diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 454a24f..c91122f 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -87,6 +87,10 @@ func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) { cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec") cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec") cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec") + cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec") + cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec") + cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec") + cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec") cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec") cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec") cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec") diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go index 33e4a56..61c0b41 100644 --- a/cmd/api/cmd_test.go +++ b/cmd/api/cmd_test.go @@ -790,6 +790,53 @@ func TestAPISpecCmd_Good_RuntimePathsPopulatedSpec(t *testing.T) { } } +func TestAPISpecCmd_Good_AuthentikFlagsPopulateSpecMetadata(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + outputFile := t.TempDir() + "/spec.json" + root.SetArgs([]string{ + "api", "spec", + "--authentik-issuer", "https://auth.example.com", + "--authentik-client-id", "core-client", + "--authentik-trusted-proxy", + "--authentik-public-paths", "/public, /docs, /public", + "--output", outputFile, + }) + root.SetErr(new(bytes.Buffer)) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("expected spec file to be written: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("expected valid JSON spec, got error: %v", err) + } + + if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" { + t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got) + } + if got := spec["x-authentik-client-id"]; got != "core-client" { + t.Fatalf("expected x-authentik-client-id=core-client, got %v", got) + } + if got := spec["x-authentik-trusted-proxy"]; got != true { + t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got) + } + publicPaths, ok := spec["x-authentik-public-paths"].([]any) + if !ok { + t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) + } + if len(publicPaths) != 2 || publicPaths[0] != "/public" || publicPaths[1] != "/docs" { + t.Fatalf("expected public paths [/public /docs], got %v", publicPaths) + } +} + func TestAPISDKCmd_Bad_EmptyLanguages(t *testing.T) { root := &cli.Command{Use: "root"} AddAPICommands(root) @@ -892,6 +939,18 @@ func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) { if sdkCmd.Flag("i18n-supported-locales") == nil { t.Fatal("expected --i18n-supported-locales flag on sdk command") } + if sdkCmd.Flag("authentik-issuer") == nil { + t.Fatal("expected --authentik-issuer flag on sdk command") + } + if sdkCmd.Flag("authentik-client-id") == nil { + t.Fatal("expected --authentik-client-id flag on sdk command") + } + if sdkCmd.Flag("authentik-trusted-proxy") == nil { + t.Fatal("expected --authentik-trusted-proxy flag on sdk command") + } + if sdkCmd.Flag("authentik-public-paths") == nil { + t.Fatal("expected --authentik-public-paths flag on sdk command") + } if sdkCmd.Flag("terms-of-service") == nil { t.Fatal("expected --terms-of-service flag on sdk command") } @@ -929,31 +988,35 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) { api.RegisterSpecGroups(specCmdStubGroup{}) builder, err := sdkSpecBuilder(specBuilderConfig{ - title: "Custom SDK API", - summary: "Custom SDK overview", - description: "Custom SDK description", - version: "9.9.9", - swaggerPath: "/docs", - graphqlPath: "/gql", - graphqlPlayground: true, - ssePath: "/events", - wsPath: "/ws", - pprofEnabled: true, - expvarEnabled: true, - cacheEnabled: true, - cacheTTL: "5m0s", - cacheMaxEntries: 42, - cacheMaxBytes: 8192, - i18nDefaultLocale: "en-GB", - i18nSupportedLocales: "en-GB,fr,en-GB", - termsURL: "https://example.com/terms", - contactName: "SDK Support", - contactURL: "https://example.com/support", - contactEmail: "support@example.com", - licenseName: "EUPL-1.2", - licenseURL: "https://eupl.eu/1.2/en/", - servers: "https://api.example.com, /, https://api.example.com", - securitySchemes: `{"apiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}}`, + title: "Custom SDK API", + summary: "Custom SDK overview", + description: "Custom SDK description", + version: "9.9.9", + swaggerPath: "/docs", + graphqlPath: "/gql", + graphqlPlayground: true, + ssePath: "/events", + wsPath: "/ws", + pprofEnabled: true, + expvarEnabled: true, + cacheEnabled: true, + cacheTTL: "5m0s", + cacheMaxEntries: 42, + cacheMaxBytes: 8192, + i18nDefaultLocale: "en-GB", + i18nSupportedLocales: "en-GB,fr,en-GB", + authentikIssuer: "https://auth.example.com", + authentikClientID: "core-client", + authentikTrustedProxy: true, + authentikPublicPaths: "/public, /docs, /public", + termsURL: "https://example.com/terms", + contactName: "SDK Support", + contactURL: "https://example.com/support", + contactEmail: "support@example.com", + licenseName: "EUPL-1.2", + licenseURL: "https://eupl.eu/1.2/en/", + servers: "https://api.example.com, /, https://api.example.com", + securitySchemes: `{"apiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}}`, }) if err != nil { t.Fatalf("unexpected error building sdk spec: %v", err) @@ -1040,6 +1103,22 @@ func TestAPISDKCmd_Good_TempSpecUsesMetadataFlags(t *testing.T) { if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" { t.Fatalf("expected supported locales [en-GB fr], got %v", locales) } + if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" { + t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got) + } + if got := spec["x-authentik-client-id"]; got != "core-client" { + t.Fatalf("expected x-authentik-client-id=core-client, got %v", got) + } + if got := spec["x-authentik-trusted-proxy"]; got != true { + t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got) + } + publicPaths, ok := spec["x-authentik-public-paths"].([]any) + if !ok { + t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) + } + if len(publicPaths) != 2 || publicPaths[0] != "/public" || publicPaths[1] != "/docs" { + t.Fatalf("expected public paths [/public /docs], got %v", publicPaths) + } if info["termsOfService"] != "https://example.com/terms" { t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"]) diff --git a/cmd/api/spec_builder.go b/cmd/api/spec_builder.go index b8785d7..5e63786 100644 --- a/cmd/api/spec_builder.go +++ b/cmd/api/spec_builder.go @@ -26,6 +26,10 @@ type specBuilderConfig struct { cacheMaxBytes int i18nDefaultLocale string i18nSupportedLocales string + authentikIssuer string + authentikClientID string + authentikTrustedProxy bool + authentikPublicPaths string termsURL string contactName string contactURL string @@ -75,6 +79,10 @@ func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) { LicenseURL: cfg.licenseURL, ExternalDocsDescription: cfg.externalDocsDescription, ExternalDocsURL: cfg.externalDocsURL, + AuthentikIssuer: strings.TrimSpace(cfg.authentikIssuer), + AuthentikClientID: strings.TrimSpace(cfg.authentikClientID), + AuthentikTrustedProxy: cfg.authentikTrustedProxy, + AuthentikPublicPaths: splitUniqueCSV(cfg.authentikPublicPaths), } builder.I18nSupportedLocales = parseLocales(cfg.i18nSupportedLocales) diff --git a/openapi.go b/openapi.go index d00ef59..854d283 100644 --- a/openapi.go +++ b/openapi.go @@ -17,7 +17,7 @@ import ( // SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups. // Title, Summary, Description, Version, and optional contact/licence/terms metadata populate the // OpenAPI info block. Top-level external documentation metadata is also supported, along with -// additive extension fields that describe runtime transport, cache, and i18n settings. +// additive extension fields that describe runtime transport, cache, i18n, and Authentik settings. // // Example: // @@ -55,6 +55,10 @@ type SpecBuilder struct { CacheMaxBytes int I18nDefaultLocale string I18nSupportedLocales []string + AuthentikIssuer string + AuthentikClientID string + AuthentikTrustedProxy bool + AuthentikPublicPaths []string } type preparedRouteGroup struct { @@ -156,6 +160,18 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if len(sb.I18nSupportedLocales) > 0 { spec["x-i18n-supported-locales"] = slices.Clone(sb.I18nSupportedLocales) } + if issuer := strings.TrimSpace(sb.AuthentikIssuer); issuer != "" { + spec["x-authentik-issuer"] = issuer + } + if clientID := strings.TrimSpace(sb.AuthentikClientID); clientID != "" { + spec["x-authentik-client-id"] = clientID + } + if sb.AuthentikTrustedProxy { + spec["x-authentik-trusted-proxy"] = true + } + if len(sb.AuthentikPublicPaths) > 0 { + spec["x-authentik-public-paths"] = slices.Clone(sb.AuthentikPublicPaths) + } if sb.TermsOfService != "" { spec["info"].(map[string]any)["termsOfService"] = sb.TermsOfService diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 1fb91fe..8319907 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -36,7 +36,7 @@ type SwaggerConfig struct { } // OpenAPISpecBuilder returns a SpecBuilder populated from the engine's current -// Swagger, transport, cache, and i18n metadata. +// Swagger, transport, cache, i18n, and Authentik metadata. // // Example: // @@ -85,6 +85,10 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder { builder.I18nDefaultLocale = runtime.I18n.DefaultLocale builder.I18nSupportedLocales = slices.Clone(runtime.I18n.Supported) + builder.AuthentikIssuer = runtime.Authentik.Issuer + builder.AuthentikClientID = runtime.Authentik.ClientID + builder.AuthentikTrustedProxy = runtime.Authentik.TrustedProxy + builder.AuthentikPublicPaths = slices.Clone(runtime.Authentik.PublicPaths) return builder } diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index 3af37ab..95d7b2c 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -38,6 +38,12 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { DefaultLocale: "en-GB", Supported: []string{"en-GB", "fr"}, }), + api.WithAuthentik(api.AuthentikConfig{ + Issuer: "https://auth.example.com", + ClientID: "core-client", + TrustedProxy: true, + PublicPaths: []string{"/public", "/docs"}, + }), api.WithWSPath("/socket"), api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")), @@ -133,6 +139,22 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) { if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" { t.Fatalf("expected supported locales [en-GB fr], got %v", locales) } + if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" { + t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got) + } + if got := spec["x-authentik-client-id"]; got != "core-client" { + t.Fatalf("expected x-authentik-client-id=core-client, got %v", got) + } + if got := spec["x-authentik-trusted-proxy"]; got != true { + t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got) + } + publicPaths, ok := spec["x-authentik-public-paths"].([]any) + if !ok { + t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"]) + } + if len(publicPaths) != 2 || publicPaths[0] != "/public" || publicPaths[1] != "/docs" { + t.Fatalf("expected public paths [/public /docs], got %v", publicPaths) + } contact, ok := info["contact"].(map[string]any) if !ok {