package middleware import ( "context" "encoding/json" "fmt" "net/http" "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/veylant/ia-gateway/internal/apierror" ) // TokenVerifier abstracts JWT verification so that the auth middleware can be // tested without a live Keycloak instance. type TokenVerifier interface { // Verify validates rawToken and returns the extracted claims on success. Verify(ctx context.Context, rawToken string) (*UserClaims, error) } // OIDCVerifier implements TokenVerifier using go-oidc against a Keycloak realm. type OIDCVerifier struct { verifier *oidc.IDTokenVerifier } // keycloakClaims mirrors the relevant claims in a Keycloak-issued JWT. type keycloakClaims struct { Sub string `json:"sub"` Email string `json:"email"` TenantID string `json:"tenant_id"` Department string `json:"department"` RealmAccess realmAccess `json:"realm_access"` } type realmAccess struct { Roles []string `json:"roles"` } // NewOIDCVerifier creates an OIDCVerifier using the Keycloak issuer URL and // the expected client ID (audience). // issuerURL example: "http://localhost:8080/realms/veylant" func NewOIDCVerifier(ctx context.Context, issuerURL, clientID string) (*OIDCVerifier, error) { provider, err := oidc.NewProvider(ctx, issuerURL) if err != nil { return nil, fmt.Errorf("creating OIDC provider for %s: %w", issuerURL, err) } v := provider.Verifier(&oidc.Config{ ClientID: clientID, }) return &OIDCVerifier{verifier: v}, nil } // Verify implements TokenVerifier. func (o *OIDCVerifier) Verify(ctx context.Context, rawToken string) (*UserClaims, error) { idToken, err := o.verifier.Verify(ctx, rawToken) if err != nil { return nil, fmt.Errorf("token verification failed: %w", err) } var kc keycloakClaims if err := idToken.Claims(&kc); err != nil { return nil, fmt.Errorf("extracting token claims: %w", err) } return &UserClaims{ UserID: kc.Sub, TenantID: kc.TenantID, Email: kc.Email, Roles: kc.RealmAccess.Roles, Department: kc.Department, }, nil } // Auth returns a middleware that enforces JWT bearer authentication. // On success the UserClaims are injected into the request context. // On failure a 401 in OpenAI error format is returned immediately. func Auth(verifier TokenVerifier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rawToken := extractBearerToken(r) if rawToken == "" { apierror.WriteError(w, apierror.NewAuthError("missing Authorization header")) return } claims, err := verifier.Verify(r.Context(), rawToken) if err != nil { apierror.WriteError(w, apierror.NewAuthError("invalid or expired token")) return } ctx := WithClaims(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // extractBearerToken extracts the token value from "Authorization: Bearer ". // Returns an empty string if the header is absent or malformed. func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if h == "" { return "" } parts := strings.SplitN(h, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { return "" } return parts[1] } // MockVerifier is a test-only TokenVerifier that returns pre-configured // claims or errors without any network call. type MockVerifier struct { Claims *UserClaims Err error } // Verify implements TokenVerifier for tests. func (m *MockVerifier) Verify(_ context.Context, _ string) (*UserClaims, error) { return m.Claims, m.Err } // mockTokenClaims is a helper to decode a mock JWT payload for testing. // It allows passing structured claims as a JSON-encoded token for the mock path. func mockTokenClaims(rawToken string) (*UserClaims, error) { var kc keycloakClaims if err := json.Unmarshal([]byte(rawToken), &kc); err != nil { return nil, fmt.Errorf("invalid mock token: %w", err) } return &UserClaims{ UserID: kc.Sub, TenantID: kc.TenantID, Email: kc.Email, Roles: kc.RealmAccess.Roles, Department: kc.Department, }, nil } // JSONMockVerifier decodes a JSON-encoded UserClaims payload directly from the // token string. Useful for integration-level unit tests. type JSONMockVerifier struct{} // Verify implements TokenVerifier by treating rawToken as a JSON-encoded UserClaims. func (j *JSONMockVerifier) Verify(_ context.Context, rawToken string) (*UserClaims, error) { return mockTokenClaims(rawToken) }