Skip to content
Open
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
138 changes: 124 additions & 14 deletions apiserver/controllers/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,6 @@ func (a *APIController) WebhookHandler(w http.ResponseWriter, r *http.Request) {

func (a *APIController) EventsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !auth.IsAdmin(ctx) {
w.WriteHeader(http.StatusForbidden)
if _, err := w.Write([]byte("events are available to admin users")); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
return
}

conn, err := a.upgrader.Upgrade(w, r, nil)
if err != nil {
Expand Down Expand Up @@ -245,13 +238,6 @@ func (a *APIController) EventsHandler(w http.ResponseWriter, r *http.Request) {

func (a *APIController) WSHandler(writer http.ResponseWriter, req *http.Request) {
ctx := req.Context()
if !auth.IsAdmin(ctx) {
writer.WriteHeader(http.StatusForbidden)
if _, err := writer.Write([]byte("you need admin level access to view logs")); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
return
}

if a.hub == nil {
handleError(ctx, writer, gErrors.NewBadRequestError("log streamer is disabled"))
Expand Down Expand Up @@ -542,3 +528,127 @@ func (a *APIController) ForceToolsSyncHandler(w http.ResponseWriter, r *http.Req
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}

// swagger:route GET /auth/oidc/status oidc OIDCStatus
//
// Returns the OIDC configuration status (enabled/disabled).
// This endpoint is public and does not require authentication.
//
// Responses:
// 200: OIDCStatusResponse
func (a *APIController) OIDCStatusHandler(w http.ResponseWriter, r *http.Request) {
response := struct {
Enabled bool `json:"enabled"`
}{
Enabled: a.auth.IsOIDCEnabled(),
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(r.Context(), "failed to encode OIDC status response")
}
}

// swagger:route GET /auth/oidc/login oidc OIDCLogin
//
// Initiates OIDC login flow by redirecting to the identity provider.
//
// Responses:
// 302: description:Redirect to OIDC provider
// 400: APIErrorResponse
// 501: APIErrorResponse
func (a *APIController) OIDCLoginHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

if !a.auth.IsOIDCEnabled() {
handleError(ctx, w, gErrors.NewBadRequestError("OIDC authentication is not enabled"))
return
}

authURL, _, err := a.auth.GetOIDCAuthURL()
if err != nil {
handleError(ctx, w, err)
return
}

http.Redirect(w, r, authURL, http.StatusFound)
}

// swagger:route GET /auth/oidc/callback oidc OIDCCallback
//
// Handles the OIDC callback from the identity provider.
//
// Responses:
// 200: JWTResponse
// 400: APIErrorResponse
// 401: APIErrorResponse
func (a *APIController) OIDCCallbackHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

if !a.auth.IsOIDCEnabled() {
handleError(ctx, w, gErrors.NewBadRequestError("OIDC authentication is not enabled"))
return
}

// Check for error from OIDC provider first (before checking for code/state)
// When the IdP returns an error (e.g., user not assigned), it won't include a code
if errParam := r.URL.Query().Get("error"); errParam != "" {
errDesc := r.URL.Query().Get("error_description")
slog.With(slog.String("error", errParam), slog.String("description", errDesc)).Error("OIDC provider returned error")
handleError(ctx, w, gErrors.NewBadRequestError("OIDC provider error: %s - %s", errParam, errDesc))
return
}

code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")

if code == "" || state == "" {
handleError(ctx, w, gErrors.NewBadRequestError("missing code or state parameter"))
return
}

ctx, err := a.auth.HandleOIDCCallback(ctx, code, state)
if err != nil {
handleError(ctx, w, err)
return
}

tokenString, err := a.auth.GetJWTToken(ctx)
if err != nil {
handleError(ctx, w, err)
return
}

// Get user info from context for the cookie
userName := auth.Username(ctx)
if userName == "" {
userName = auth.UserID(ctx)
}

// Set cookies for the webapp
// Token cookie - NOT HttpOnly because the webapp JavaScript needs to read it
// to set it in the API client for authenticated requests
http.SetCookie(w, &http.Cookie{
Name: "garm_token",
Value: tokenString,
Path: "/",
HttpOnly: false,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 7, // 7 days
})

// User cookie - accessible to JavaScript for display purposes
http.SetCookie(w, &http.Cookie{
Name: "garm_user",
Value: userName,
Path: "/",
HttpOnly: false,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
MaxAge: 86400 * 7, // 7 days
})

// Redirect to the webapp
http.Redirect(w, r, "/ui/", http.StatusFound)
}
45 changes: 45 additions & 0 deletions apiserver/controllers/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2022 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.

package controllers

import (
"encoding/json"
"log/slog"
"net/http"
)

// swagger:route GET /users users ListUsers
//
// List all users.
//
// Responses:
// 200: Users
// default: APIErrorResponse
func (a *APIController) ListUsersHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

users, err := a.r.ListUsers(ctx)
if err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing users")
handleError(ctx, w, err)
return
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(users); err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
}
}

16 changes: 16 additions & 0 deletions apiserver/routers/routers.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
authRouter.Handle("/{login:login\\/?}", http.HandlerFunc(han.LoginHandler)).Methods("POST", "OPTIONS")
authRouter.Use(initMiddleware.Middleware)

// OIDC authentication routes (no auth middleware - these initiate/complete auth)
oidcRouter := apiSubRouter.PathPrefix("/auth/oidc").Subrouter()
oidcRouter.Handle("/status/", http.HandlerFunc(han.OIDCStatusHandler)).Methods("GET", "OPTIONS")
oidcRouter.Handle("/status", http.HandlerFunc(han.OIDCStatusHandler)).Methods("GET", "OPTIONS")
oidcRouter.Handle("/login/", http.HandlerFunc(han.OIDCLoginHandler)).Methods("GET", "OPTIONS")
oidcRouter.Handle("/login", http.HandlerFunc(han.OIDCLoginHandler)).Methods("GET", "OPTIONS")
oidcRouter.Handle("/callback/", http.HandlerFunc(han.OIDCCallbackHandler)).Methods("GET", "OPTIONS")
oidcRouter.Handle("/callback", http.HandlerFunc(han.OIDCCallbackHandler)).Methods("GET", "OPTIONS")

//////////////////////////
// Controller endpoints //
//////////////////////////
Expand Down Expand Up @@ -242,6 +251,13 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")

///////////
// Users //
///////////
// List users
apiRouter.Handle("/users/", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS")
apiRouter.Handle("/users", http.HandlerFunc(han.ListUsersHandler)).Methods("GET", "OPTIONS")

/////////////
// Objects //
/////////////
Expand Down
7 changes: 7 additions & 0 deletions auth/admin_required.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import "net/http"

func AdminRequiredMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow read-only methods for all authenticated users
// Only require admin for mutating operations
if r.Method == http.MethodGet || r.Method == http.MethodOptions || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}

ctx := r.Context()
if !IsAdmin(ctx) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
Expand Down
Loading