Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,28 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli
}
apikeyAuth := inbound.NewAPIKey(keyStore, cfg.APIKeys.HeaderName, cfg.APIKeys.QueryParamName)
bearerAuth := inbound.NewBearer(cfg.Bearer.Tokens)
app.chain = inbound.NewChain(cfg.Auth.AllowAnonymous, apikeyAuth, bearerAuth)

// The OIDC validator is shared with the portal's BrowserAuth so we hit
// discovery + JWKS once. When portal is disabled but oidc.enabled is
// true the validator still belongs in the chain so /v1/* accepts JWTs.
var oidcValidator *auth.OIDCAuthenticator
if cfg.OIDC.Enabled {
v, err := auth.NewOIDC(ctx, cfg.OIDC)
if err != nil {
return nil, fmt.Errorf("oidc validator: %w", err)
}
oidcValidator = v
}

auths := []inbound.Authenticator{apikeyAuth}
if oidcValidator != nil {
// Insert OIDC before the static bearer so JWTs hit the IdP-aware
// validator first. A non-JWT bearer returns ErrNoCredential and
// falls through to the static list.
auths = append(auths, inbound.NewOIDCBearer(oidcValidator))
}
auths = append(auths, bearerAuth)
app.chain = inbound.NewChain(cfg.Auth.AllowAnonymous, auths...)

// --- Endpoint registry ---
app.registry = buildRegistry(cfg)
Expand All @@ -119,7 +140,7 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli
app.readiness = httpsrv.NewReadiness()

// --- Portal (M3+) ---
portalDeps, err := buildPortal(ctx, cfg, app.chain, app.auditLog, app.registry, app.dbKeys, logger)
portalDeps, err := buildPortal(ctx, cfg, app.chain, app.auditLog, app.registry, app.dbKeys, oidcValidator, logger)
if err != nil {
return nil, fmt.Errorf("portal: %w", err)
}
Expand Down Expand Up @@ -160,16 +181,18 @@ func buildOpenAPI(cfg *config.Config, registry *endpoints.Registry) oapi.Documen
// true. Returns (nil, nil) when the portal is disabled — the mux falls back
// to the bare /v1/* + /healthz surface.
//
// The OIDC validator + BrowserAuth construction will hit the configured
// issuer's discovery URL at startup; misconfiguration (wrong issuer, IdP
// down) fails Build() rather than the first portal request.
// oidcValidator is the same instance the inbound chain uses; the portal
// reuses it rather than running discovery + JWKS fetch a second time. When
// cfg.OIDC.Enabled is false the validator is nil and BrowserAuth is not
// mounted.
func buildPortal(
ctx context.Context,
cfg *config.Config,
chain *inbound.Chain,
auditLog audit.Logger,
registry *endpoints.Registry,
keys *apikeys.Store,
oidcValidator *auth.OIDCAuthenticator,
logger *slog.Logger,
) (*httpsrv.PortalDeps, error) {
if !cfg.Portal.Enabled {
Expand All @@ -189,12 +212,8 @@ func buildPortal(
PortalAuth: httpsrv.NewPortalAuth(sessions, chain),
PortalAPI: httpsrv.NewPortalAPI(cfg, registry, auditLog, keys),
}
if cfg.OIDC.Enabled {
validator, err := auth.NewOIDC(ctx, cfg.OIDC)
if err != nil {
return nil, fmt.Errorf("oidc validator: %w", err)
}
ba, err := httpsrv.NewBrowserAuth(ctx, cfg, validator, sessions, logger)
if oidcValidator != nil {
ba, err := httpsrv.NewBrowserAuth(ctx, cfg, oidcValidator, sessions, logger)
if err != nil {
return nil, fmt.Errorf("browser auth: %w", err)
}
Expand Down
99 changes: 99 additions & 0 deletions pkg/auth/inbound/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package inbound

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/plexara/api-test/pkg/auth"
)

// OIDCValidator is the subset of pkg/auth.OIDCAuthenticator the inbound
// adapter needs. Defined as an interface so tests can stub validation
// without standing up a fake IdP.
type OIDCValidator interface {
ValidateBearer(ctx context.Context, token string) (*auth.Identity, error)
}

// OIDCBearerAuthenticator adapts an OIDC JWT validator to the inbound
// chain. It guards the /v1/* surface so callers can authenticate with
// `Authorization: Bearer <jwt>` issued by the configured IdP.
//
// Chain semantics:
// - No Authorization header / non-Bearer scheme → ErrNoCredential.
// - Bearer value that is not a structurally-valid JWT → ErrNoCredential.
// This lets a static dev token fall through to BearerAuthenticator
// when both are registered in the chain.
// - Structurally-valid JWT that fails signature/issuer/audience/expiry
// checks → ErrInvalidCredential (no fallthrough — a real JWT that
// fails verification must 401, not be retried as a static bearer).
type OIDCBearerAuthenticator struct {
validator OIDCValidator
}

// NewOIDCBearer returns an OIDCBearerAuthenticator wrapping v. A nil
// validator yields an authenticator that always returns ErrNoCredential,
// so callers can register the adapter unconditionally.
func NewOIDCBearer(v OIDCValidator) *OIDCBearerAuthenticator {
return &OIDCBearerAuthenticator{validator: v}
}

// Authenticate implements Authenticator.
func (a *OIDCBearerAuthenticator) Authenticate(ctx context.Context, r *http.Request) (*Identity, error) {
if a == nil || a.validator == nil {
return nil, ErrNoCredential
}
token := extractBearer(r.Header.Get("Authorization"))
if token == "" {
return nil, ErrNoCredential
}
if !looksLikeJWT(token) {
return nil, ErrNoCredential
}
aid, err := a.validator.ValidateBearer(ctx, token)
if err != nil {
// Wrap both the sentinel (so callers can errors.Is for chain
// semantics) and the underlying verifier error (so operators see
// why the JWT was rejected in logs).
return nil, fmt.Errorf("oidc: %w: %w", ErrInvalidCredential, err)
}
subject := aid.Subject
if subject == "" {
// ValidateBearer already falls back to preferred_username; this is
// just a safety net so an audit row never has an empty subject.
subject = "oidc-subject-missing"
}
return &Identity{
Subject: subject,
Email: aid.Email,
AuthType: "oidc",
KeyName: subject,
Claims: aid.Claims,
}, nil
}

// looksLikeJWT is a cheap structural check: three dot-separated segments
// where the first segment base64url-decodes to JSON carrying a non-empty
// "alg" header. We gate the IdP-aware validator behind this so a static
// dev token like "abc123" returns ErrNoCredential and the chain falls
// through to BearerAuthenticator.
func looksLikeJWT(s string) bool {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return false
}
header, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var h struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(header, &h); err != nil {
return false
}
return h.Alg != ""
}
Loading
Loading