feat(api): surface authentik metadata in specs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 13:25:23 +00:00
parent eb18611dc1
commit f234fcba5f
7 changed files with 164 additions and 27 deletions

View file

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

View file

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

View file

@ -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"])

View file

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

View file

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

View file

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

View file

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