Skip to content

Commit c399443

Browse files
authored
feat: OIDC connector config and provider discovery API (#1548)
* feat: add OIDC connector configuration and provider discovery API Add external OIDC connector configuration model (Task 18) supporting Google, GitHub, Microsoft, and generic OIDC providers. Configuration can be loaded from DEX_CONNECTORS JSON or individual environment variables (DEX_CONNECTOR_{TYPE}_{FIELD}). Add GET /api/auth/providers endpoint (Task 19) to the API gateway that returns configured authentication providers for frontend login button rendering. The endpoint is public (no auth required) and includes Cache-Control headers. * fix: log error when AUTH_PROVIDERS JSON is malformed Previously, invalid JSON in AUTH_PROVIDERS was silently swallowed, causing all OIDC login buttons to disappear with no log trace. Now logs an error before falling back to the default provider. * fix: remove undocumented AUTH_DEFAULT_PROVIDER from doc comment The environment variable was documented but never read by the loader. * fix: filter OIDC providers without authUrl when DEX_ISSUER is unset OIDC providers configured without an explicit authUrl and no DEX_ISSUER to derive one from are now logged and filtered out rather than published with an empty auth URL in the discovery response. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 96452ed commit c399443

5 files changed

Lines changed: 810 additions & 0 deletions

File tree

services/api-gateway/providers.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package gateway
2+
3+
import (
4+
"encoding/json"
5+
"log/slog"
6+
"net/http"
7+
"os"
8+
"strings"
9+
)
10+
11+
// AuthProvider represents a configured authentication provider returned by the
12+
// provider discovery endpoint. The frontend uses this to render login buttons.
13+
type AuthProvider struct {
14+
// ID is the unique identifier for this provider (e.g., "meridian", "google").
15+
ID string `json:"id"`
16+
// Type is the provider type: "password" for local login, "oidc" for external.
17+
Type string `json:"type"`
18+
// DisplayName is the human-readable label for the login button.
19+
DisplayName string `json:"displayName"`
20+
// AuthURL is the Dex authorization URL for OIDC providers.
21+
// Empty for password-type providers.
22+
AuthURL string `json:"authUrl,omitempty"`
23+
}
24+
25+
// ProvidersResponse is the JSON response for GET /api/auth/providers.
26+
type ProvidersResponse struct {
27+
Providers []AuthProvider `json:"providers"`
28+
}
29+
30+
// ProvidersConfig holds the configuration for the provider discovery endpoint.
31+
type ProvidersConfig struct {
32+
// Enabled controls whether the /api/auth/providers endpoint is registered.
33+
Enabled bool
34+
// Providers is the list of configured authentication providers.
35+
Providers []AuthProvider
36+
}
37+
38+
// LoadProvidersConfig loads provider configuration from environment variables.
39+
//
40+
// Environment variables:
41+
// - AUTH_PROVIDERS_ENABLED: Enable the /api/auth/providers endpoint (default: false)
42+
// - AUTH_PROVIDERS: JSON array of provider objects
43+
// - DEX_ISSUER: Used to construct authUrl for OIDC providers when not explicitly set
44+
//
45+
// When AUTH_PROVIDERS is not set but AUTH_PROVIDERS_ENABLED is true, a default
46+
// "meridian" password provider is included.
47+
func LoadProvidersConfig() ProvidersConfig {
48+
config := ProvidersConfig{
49+
Enabled: getEnvBool("AUTH_PROVIDERS_ENABLED", false),
50+
}
51+
52+
if !config.Enabled {
53+
return config
54+
}
55+
56+
dexIssuer := strings.TrimRight(os.Getenv("DEX_ISSUER"), "/")
57+
58+
if raw := os.Getenv("AUTH_PROVIDERS"); raw != "" {
59+
var providers []AuthProvider
60+
if err := json.Unmarshal([]byte(raw), &providers); err != nil {
61+
slog.Error("failed to parse AUTH_PROVIDERS, falling back to default provider",
62+
"error", err)
63+
} else {
64+
// Populate authUrl for OIDC providers and filter out those
65+
// that would have no usable auth URL.
66+
filtered := make([]AuthProvider, 0, len(providers))
67+
for i := range providers {
68+
if providers[i].Type == "oidc" && providers[i].AuthURL == "" {
69+
if dexIssuer == "" {
70+
slog.Error("skipping OIDC provider without authUrl and DEX_ISSUER",
71+
"provider_id", providers[i].ID)
72+
continue
73+
}
74+
providers[i].AuthURL = dexIssuer + "/auth/" + providers[i].ID
75+
}
76+
filtered = append(filtered, providers[i])
77+
}
78+
config.Providers = filtered
79+
}
80+
}
81+
82+
// Default to just the local password provider if nothing configured
83+
if len(config.Providers) == 0 {
84+
config.Providers = []AuthProvider{
85+
{ID: "meridian", Type: "password", DisplayName: "Email & Password"},
86+
}
87+
}
88+
89+
return config
90+
}
91+
92+
// getEnvBool parses a boolean from an environment variable.
93+
func getEnvBool(key string, defaultVal bool) bool {
94+
v := os.Getenv(key)
95+
switch strings.ToLower(v) {
96+
case "true", "1", "yes":
97+
return true
98+
case "false", "0", "no":
99+
return false
100+
default:
101+
return defaultVal
102+
}
103+
}
104+
105+
// handleProviders returns the configured authentication providers as JSON.
106+
// This endpoint is public (no auth required) because the frontend needs
107+
// to know which login methods are available before the user authenticates.
108+
func (s *Server) handleProviders(w http.ResponseWriter, _ *http.Request) {
109+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
110+
w.Header().Set("Cache-Control", "public, max-age=300")
111+
112+
resp := ProvidersResponse{Providers: s.providersConfig.Providers}
113+
if resp.Providers == nil {
114+
resp.Providers = []AuthProvider{}
115+
}
116+
117+
_ = json.NewEncoder(w).Encode(resp)
118+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package gateway
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestProvidersEndpoint_ReturnsProviders(t *testing.T) {
16+
providers := []AuthProvider{
17+
{ID: "meridian", Type: "password", DisplayName: "Email & Password"},
18+
{ID: "google", Type: "oidc", DisplayName: "Google", AuthURL: "https://dex.example.com/dex/auth/google"},
19+
}
20+
21+
server := newTestServerWithProviders(t, ProvidersConfig{
22+
Enabled: true,
23+
Providers: providers,
24+
})
25+
26+
req := httptest.NewRequest(http.MethodGet, "/api/auth/providers", nil)
27+
w := httptest.NewRecorder()
28+
server.mux.ServeHTTP(w, req)
29+
30+
resp := w.Result()
31+
defer resp.Body.Close()
32+
33+
assert.Equal(t, http.StatusOK, resp.StatusCode)
34+
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
35+
assert.Equal(t, "public, max-age=300", resp.Header.Get("Cache-Control"))
36+
37+
body, err := io.ReadAll(resp.Body)
38+
require.NoError(t, err)
39+
40+
var result ProvidersResponse
41+
require.NoError(t, json.Unmarshal(body, &result))
42+
require.Len(t, result.Providers, 2)
43+
assert.Equal(t, "meridian", result.Providers[0].ID)
44+
assert.Equal(t, "password", result.Providers[0].Type)
45+
assert.Equal(t, "google", result.Providers[1].ID)
46+
assert.Equal(t, "oidc", result.Providers[1].Type)
47+
assert.Equal(t, "https://dex.example.com/dex/auth/google", result.Providers[1].AuthURL)
48+
}
49+
50+
func TestProvidersEndpoint_EmptyProviders_ReturnsEmptyArray(t *testing.T) {
51+
server := newTestServerWithProviders(t, ProvidersConfig{
52+
Enabled: true,
53+
Providers: nil,
54+
})
55+
56+
req := httptest.NewRequest(http.MethodGet, "/api/auth/providers", nil)
57+
w := httptest.NewRecorder()
58+
server.mux.ServeHTTP(w, req)
59+
60+
resp := w.Result()
61+
defer resp.Body.Close()
62+
63+
body, err := io.ReadAll(resp.Body)
64+
require.NoError(t, err)
65+
66+
var result ProvidersResponse
67+
require.NoError(t, json.Unmarshal(body, &result))
68+
assert.NotNil(t, result.Providers)
69+
assert.Empty(t, result.Providers)
70+
}
71+
72+
func TestProvidersEndpoint_PostReturns405(t *testing.T) {
73+
server := newTestServerWithProviders(t, ProvidersConfig{
74+
Enabled: true,
75+
Providers: []AuthProvider{{ID: "meridian", Type: "password", DisplayName: "Email & Password"}},
76+
})
77+
78+
req := httptest.NewRequest(http.MethodPost, "/api/auth/providers", nil)
79+
w := httptest.NewRecorder()
80+
server.mux.ServeHTTP(w, req)
81+
82+
assert.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode)
83+
}
84+
85+
func TestProvidersEndpoint_DisabledReturns404(t *testing.T) {
86+
server := newTestServerWithProviders(t, ProvidersConfig{
87+
Enabled: false,
88+
})
89+
90+
req := httptest.NewRequest(http.MethodGet, "/api/auth/providers", nil)
91+
w := httptest.NewRecorder()
92+
server.mux.ServeHTTP(w, req)
93+
94+
// When disabled, the route is not registered. The "/" catch-all will handle it.
95+
// With no backend configured, it returns 503.
96+
resp := w.Result()
97+
defer resp.Body.Close()
98+
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
99+
}
100+
101+
func TestProvidersEndpoint_OmitsEmptyAuthURL(t *testing.T) {
102+
server := newTestServerWithProviders(t, ProvidersConfig{
103+
Enabled: true,
104+
Providers: []AuthProvider{
105+
{ID: "meridian", Type: "password", DisplayName: "Email & Password"},
106+
},
107+
})
108+
109+
req := httptest.NewRequest(http.MethodGet, "/api/auth/providers", nil)
110+
w := httptest.NewRecorder()
111+
server.mux.ServeHTTP(w, req)
112+
113+
body, err := io.ReadAll(w.Result().Body)
114+
require.NoError(t, err)
115+
116+
// authUrl should be omitted (omitempty) for password type
117+
assert.NotContains(t, string(body), "authUrl")
118+
}
119+
120+
func TestLoadProvidersConfig_Disabled(t *testing.T) {
121+
cfg := LoadProvidersConfig()
122+
assert.False(t, cfg.Enabled)
123+
assert.Nil(t, cfg.Providers)
124+
}
125+
126+
func TestLoadProvidersConfig_EnabledWithDefaults(t *testing.T) {
127+
t.Setenv("AUTH_PROVIDERS_ENABLED", "true")
128+
129+
cfg := LoadProvidersConfig()
130+
assert.True(t, cfg.Enabled)
131+
require.Len(t, cfg.Providers, 1)
132+
assert.Equal(t, "meridian", cfg.Providers[0].ID)
133+
assert.Equal(t, "password", cfg.Providers[0].Type)
134+
}
135+
136+
func TestLoadProvidersConfig_JSONProviders(t *testing.T) {
137+
t.Setenv("AUTH_PROVIDERS_ENABLED", "true")
138+
t.Setenv("AUTH_PROVIDERS", `[
139+
{"id":"meridian","type":"password","displayName":"Email & Password"},
140+
{"id":"google","type":"oidc","displayName":"Google"}
141+
]`)
142+
t.Setenv("DEX_ISSUER", "https://dex.example.com/dex")
143+
144+
cfg := LoadProvidersConfig()
145+
assert.True(t, cfg.Enabled)
146+
require.Len(t, cfg.Providers, 2)
147+
assert.Equal(t, "meridian", cfg.Providers[0].ID)
148+
assert.Empty(t, cfg.Providers[0].AuthURL) // password type gets no authUrl
149+
assert.Equal(t, "google", cfg.Providers[1].ID)
150+
assert.Equal(t, "https://dex.example.com/dex/auth/google", cfg.Providers[1].AuthURL)
151+
}
152+
153+
func TestLoadProvidersConfig_ExplicitAuthURL(t *testing.T) {
154+
t.Setenv("AUTH_PROVIDERS_ENABLED", "true")
155+
t.Setenv("AUTH_PROVIDERS", `[{"id":"custom","type":"oidc","displayName":"Custom","authUrl":"https://custom.example.com/auth"}]`)
156+
t.Setenv("DEX_ISSUER", "https://dex.example.com/dex")
157+
158+
cfg := LoadProvidersConfig()
159+
require.Len(t, cfg.Providers, 1)
160+
// Explicit authUrl should NOT be overwritten
161+
assert.Equal(t, "https://custom.example.com/auth", cfg.Providers[0].AuthURL)
162+
}
163+
164+
func TestLoadProvidersConfig_OIDCWithoutIssuer_Filtered(t *testing.T) {
165+
t.Setenv("AUTH_PROVIDERS_ENABLED", "true")
166+
t.Setenv("AUTH_PROVIDERS", `[
167+
{"id":"meridian","type":"password","displayName":"Email & Password"},
168+
{"id":"google","type":"oidc","displayName":"Google"}
169+
]`)
170+
// No DEX_ISSUER set
171+
172+
cfg := LoadProvidersConfig()
173+
// Google OIDC provider should be filtered out (no authUrl, no DEX_ISSUER)
174+
require.Len(t, cfg.Providers, 1)
175+
assert.Equal(t, "meridian", cfg.Providers[0].ID)
176+
}
177+
178+
func TestLoadProvidersConfig_InvalidJSON_FallsBackToDefault(t *testing.T) {
179+
t.Setenv("AUTH_PROVIDERS_ENABLED", "true")
180+
t.Setenv("AUTH_PROVIDERS", `{invalid`)
181+
182+
cfg := LoadProvidersConfig()
183+
require.Len(t, cfg.Providers, 1)
184+
assert.Equal(t, "meridian", cfg.Providers[0].ID)
185+
}
186+
187+
func newTestServerWithProviders(t *testing.T, providersCfg ProvidersConfig) *Server {
188+
t.Helper()
189+
config := &Config{
190+
Port: 8080,
191+
BaseDomain: "test.example.com",
192+
DatabaseURL: "postgres://test",
193+
LocalDevMode: true,
194+
}
195+
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
196+
return NewServer(config, logger, nil, WithProvidersConfig(providersCfg))
197+
}

services/api-gateway/server.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Server struct {
3434
eventStreamHandler *eventstream.Handler
3535
rawEventStreamHandler http.Handler // used by tests and WithEventStreamHandlerHTTP
3636
versionInfo *VersionInfo
37+
providersConfig ProvidersConfig
3738
}
3839

3940
// ServerOption is a functional option for configuring the server.
@@ -86,6 +87,14 @@ func WithVersionInfo(info *VersionInfo) ServerOption {
8687
}
8788
}
8889

90+
// WithProvidersConfig sets the authentication provider discovery configuration.
91+
// When enabled, the GET /api/auth/providers endpoint is registered.
92+
func WithProvidersConfig(cfg ProvidersConfig) ServerOption {
93+
return func(s *Server) {
94+
s.providersConfig = cfg
95+
}
96+
}
97+
8998
// NewServer creates a new gateway HTTP server with the given configuration.
9099
// The tenantResolver parameter is optional - if nil, all routes bypass tenant resolution.
91100
// Additional options can configure authentication middleware.
@@ -145,6 +154,11 @@ func (s *Server) registerRoutes() {
145154
// Build version endpoint - NO middleware (public, like health)
146155
s.mux.HandleFunc("/version", s.getOnly(s.handleVersion))
147156

157+
// Provider discovery endpoint - NO middleware (public, needed before login)
158+
if s.providersConfig.Enabled {
159+
s.mux.HandleFunc("/api/auth/providers", s.getOnly(s.handleProviders))
160+
}
161+
148162
// API routes - with auth and tenant middleware chain.
149163
// Prefer the Vanguard transcoder when configured; fall back to the legacy
150164
// prefix-based reverse proxy when Backends are provided; otherwise use a

0 commit comments

Comments
 (0)