package api import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kindredsystems/silo/internal/auth" ) // HandleAuthConfig returns public auth configuration for the login page. func (s *Server) HandleAuthConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "oidc_enabled": s.oidc != nil, "local_enabled": s.authConfig != nil && s.authConfig.Local.Enabled, }) } // HandleLoginPage redirects to the SPA (React handles the login UI). func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } // HandleLogin processes the login form submission. func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { if s.authConfig == nil || !s.authConfig.Enabled { http.Redirect(w, r, "/", http.StatusSeeOther) return } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") if username == "" || password == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Username and password are required") return } user, err := s.auth.Authenticate(r.Context(), username, password) if err != nil { s.logger.Warn().Str("username", username).Err(err).Msg("login failed") writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password") return } // Create session if err := s.sessions.RenewToken(r.Context()); err != nil { s.logger.Error().Err(err).Msg("failed to renew session token") writeError(w, http.StatusInternalServerError, "internal_error", "Internal error, please try again") return } s.sessions.Put(r.Context(), "user_id", user.ID) s.sessions.Put(r.Context(), "username", user.Username) s.logger.Info().Str("username", username).Str("source", user.AuthSource).Msg("user logged in") // Redirect to original destination or home next := r.URL.Query().Get("next") if next == "" || !strings.HasPrefix(next, "/") { next = "/" } http.Redirect(w, r, next, http.StatusSeeOther) } // HandleLogout destroys the session and redirects to login. func (s *Server) HandleLogout(w http.ResponseWriter, r *http.Request) { if s.sessions != nil { _ = s.sessions.Destroy(r.Context()) } http.Redirect(w, r, "/login", http.StatusSeeOther) } // HandleOIDCLogin initiates the OIDC redirect to Keycloak. func (s *Server) HandleOIDCLogin(w http.ResponseWriter, r *http.Request) { if s.oidc == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } state, err := generateRandomState() if err != nil { s.logger.Error().Err(err).Msg("failed to generate OIDC state") http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.sessions.Put(r.Context(), "oidc_state", state) http.Redirect(w, r, s.oidc.AuthCodeURL(state), http.StatusSeeOther) } // HandleOIDCCallback processes the OIDC redirect from Keycloak. func (s *Server) HandleOIDCCallback(w http.ResponseWriter, r *http.Request) { if s.oidc == nil { http.Error(w, "OIDC not configured", http.StatusNotFound) return } // Verify state expectedState := s.sessions.GetString(r.Context(), "oidc_state") actualState := r.URL.Query().Get("state") if expectedState == "" || actualState != expectedState { s.logger.Warn().Msg("OIDC state mismatch") http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.sessions.Remove(r.Context(), "oidc_state") // Check for error from IdP if errParam := r.URL.Query().Get("error"); errParam != "" { desc := r.URL.Query().Get("error_description") s.logger.Warn().Str("error", errParam).Str("description", desc).Msg("OIDC error from IdP") http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Exchange code for token code := r.URL.Query().Get("code") user, err := s.oidc.Exchange(r.Context(), code) if err != nil { s.logger.Error().Err(err).Msg("OIDC exchange failed") http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Upsert user into DB if err := s.auth.UpsertOIDCUser(r.Context(), user); err != nil { s.logger.Error().Err(err).Msg("failed to upsert OIDC user") http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Create session if err := s.sessions.RenewToken(r.Context()); err != nil { s.logger.Error().Err(err).Msg("failed to renew session token") http.Redirect(w, r, "/login", http.StatusSeeOther) return } s.sessions.Put(r.Context(), "user_id", user.ID) s.sessions.Put(r.Context(), "username", user.Username) s.logger.Info().Str("username", user.Username).Msg("OIDC user logged in") http.Redirect(w, r, "/", http.StatusSeeOther) } // HandleGetCurrentUser returns the authenticated user as JSON. func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated") return } writeJSON(w, http.StatusOK, map[string]any{ "id": user.ID, "username": user.Username, "display_name": user.DisplayName, "email": user.Email, "role": user.Role, "auth_source": user.AuthSource, }) } // createTokenRequest is the request body for token creation. type createTokenRequest struct { Name string `json:"name"` ExpiresInDays *int `json:"expires_in_days,omitempty"` } // HandleCreateToken creates a new API token (JSON API). func (s *Server) HandleCreateToken(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated") return } var req createTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", "Invalid JSON body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Token name is required") return } var expiresAt *time.Time if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 { t := time.Now().AddDate(0, 0, *req.ExpiresInDays) expiresAt = &t } rawToken, info, err := s.auth.GenerateToken(r.Context(), user.ID, req.Name, nil, expiresAt) if err != nil { s.logger.Error().Err(err).Msg("failed to generate token") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create token") return } writeJSON(w, http.StatusCreated, map[string]any{ "token": rawToken, "id": info.ID, "name": info.Name, "token_prefix": info.TokenPrefix, "expires_at": info.ExpiresAt, "created_at": info.CreatedAt, }) } // HandleListTokens lists all tokens for the current user. func (s *Server) HandleListTokens(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated") return } tokens, err := s.auth.ListTokens(r.Context(), user.ID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list tokens") return } type tokenResponse struct { ID string `json:"id"` Name string `json:"name"` TokenPrefix string `json:"token_prefix"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` CreatedAt time.Time `json:"created_at"` } result := make([]tokenResponse, 0, len(tokens)) for _, t := range tokens { result = append(result, tokenResponse{ ID: t.ID, Name: t.Name, TokenPrefix: t.TokenPrefix, LastUsedAt: t.LastUsedAt, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, }) } writeJSON(w, http.StatusOK, result) } // HandleRevokeToken deletes an API token (JSON API). func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Not authenticated") return } tokenID := chi.URLParam(r, "id") if err := s.auth.RevokeToken(r.Context(), user.ID, tokenID); err != nil { writeError(w, http.StatusNotFound, "not_found", "Token not found") return } w.WriteHeader(http.StatusNoContent) } func generateRandomState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }